diff --git a/.buildkite/ftr_platform_stateful_configs.yml b/.buildkite/ftr_platform_stateful_configs.yml index d8a538f82779e..fbbfc25e89a26 100644 --- a/.buildkite/ftr_platform_stateful_configs.yml +++ b/.buildkite/ftr_platform_stateful_configs.yml @@ -315,6 +315,7 @@ enabled: - x-pack/test/security_api_integration/saml.http2.config.ts - x-pack/test/security_api_integration/saml_cloud.config.ts - x-pack/test/security_api_integration/chips.config.ts + - x-pack/test/security_api_integration/features.config.ts - x-pack/test/security_api_integration/session_idle.config.ts - x-pack/test/security_api_integration/session_invalidate.config.ts - x-pack/test/security_api_integration/session_lifespan.config.ts diff --git a/package.json b/package.json index dc26a2313d09b..debbdf1c1b894 100644 --- a/package.json +++ b/package.json @@ -522,6 +522,7 @@ "@kbn/feature-flags-example-plugin": "link:examples/feature_flags_example", "@kbn/feature-usage-test-plugin": "link:x-pack/test/plugin_api_integration/plugins/feature_usage_test", "@kbn/features-plugin": "link:x-pack/plugins/features", + "@kbn/features-provider-plugin": "link:x-pack/test/security_api_integration/plugins/features_provider", "@kbn/fec-alerts-test-plugin": "link:x-pack/test/functional_execution_context/plugins/alerts", "@kbn/field-formats-example-plugin": "link:examples/field_formats_example", "@kbn/field-formats-plugin": "link:src/plugins/field_formats", @@ -797,6 +798,7 @@ "@kbn/searchprofiler-plugin": "link:x-pack/plugins/searchprofiler", "@kbn/security-api-key-management": "link:x-pack/packages/security/api_key_management", "@kbn/security-authorization-core": "link:x-pack/packages/security/authorization_core", + "@kbn/security-authorization-core-common": "link:x-pack/packages/security/authorization_core_common", "@kbn/security-form-components": "link:x-pack/packages/security/form_components", "@kbn/security-hardening": "link:packages/kbn-security-hardening", "@kbn/security-plugin": "link:x-pack/plugins/security", diff --git a/packages/kbn-ftr-common-functional-ui-services/services/security/role.ts b/packages/kbn-ftr-common-functional-ui-services/services/security/role.ts index 88ea94439984f..df8cd737b8d92 100644 --- a/packages/kbn-ftr-common-functional-ui-services/services/security/role.ts +++ b/packages/kbn-ftr-common-functional-ui-services/services/security/role.ts @@ -14,6 +14,28 @@ import { KbnClient } from '@kbn/test'; export class Role { constructor(private log: ToolingLog, private kibanaServer: KbnClient) {} + public async get( + name: string, + { replaceDeprecatedPrivileges = true }: { replaceDeprecatedPrivileges?: boolean } = {} + ) { + this.log.debug(`retrieving role ${name}`); + const { data, status, statusText } = await this.kibanaServer + .request({ + path: `/api/security/role/${name}?replaceDeprecatedPrivileges=${replaceDeprecatedPrivileges}`, + method: 'GET', + }) + .catch((e) => { + throw new Error(util.inspect(e.axiosError.response, true)); + }); + if (status !== 200) { + throw new Error( + `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + + return data; + } + public async create(name: string, role: any) { this.log.debug(`creating role ${name}`); const { data, status, statusText } = await this.kibanaServer.request({ diff --git a/tsconfig.base.json b/tsconfig.base.json index 2e11f197f0ffe..76bd49c7678b5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -926,6 +926,8 @@ "@kbn/feature-usage-test-plugin/*": ["x-pack/test/plugin_api_integration/plugins/feature_usage_test/*"], "@kbn/features-plugin": ["x-pack/plugins/features"], "@kbn/features-plugin/*": ["x-pack/plugins/features/*"], + "@kbn/features-provider-plugin": ["x-pack/test/security_api_integration/plugins/features_provider"], + "@kbn/features-provider-plugin/*": ["x-pack/test/security_api_integration/plugins/features_provider/*"], "@kbn/fec-alerts-test-plugin": ["x-pack/test/functional_execution_context/plugins/alerts"], "@kbn/fec-alerts-test-plugin/*": ["x-pack/test/functional_execution_context/plugins/alerts/*"], "@kbn/field-formats-example-plugin": ["examples/field_formats_example"], @@ -1548,6 +1550,8 @@ "@kbn/security-api-key-management/*": ["x-pack/packages/security/api_key_management/*"], "@kbn/security-authorization-core": ["x-pack/packages/security/authorization_core"], "@kbn/security-authorization-core/*": ["x-pack/packages/security/authorization_core/*"], + "@kbn/security-authorization-core-common": ["x-pack/packages/security/authorization_core_common"], + "@kbn/security-authorization-core-common/*": ["x-pack/packages/security/authorization_core_common/*"], "@kbn/security-form-components": ["x-pack/packages/security/form_components"], "@kbn/security-form-components/*": ["x-pack/packages/security/form_components/*"], "@kbn/security-hardening": ["packages/kbn-security-hardening"], diff --git a/x-pack/packages/security/authorization_core/index.ts b/x-pack/packages/security/authorization_core/index.ts index ccb68eb3bbcec..dc85fee1f0657 100644 --- a/x-pack/packages/security/authorization_core/index.ts +++ b/x-pack/packages/security/authorization_core/index.ts @@ -6,10 +6,5 @@ */ export { Actions } from './src/actions'; -export { privilegesFactory } from './src/privileges'; -export type { - CasesSupportedOperations, - PrivilegesService, - RawKibanaPrivileges, - RawKibanaFeaturePrivileges, -} from './src/privileges'; +export { privilegesFactory, getReplacedByForPrivilege } from './src/privileges'; +export type { CasesSupportedOperations, PrivilegesService } from './src/privileges'; diff --git a/x-pack/packages/security/authorization_core/src/actions/alerting.test.ts b/x-pack/packages/security/authorization_core/src/actions/alerting.test.ts index 8f3d48a91005c..1db1030da510a 100644 --- a/x-pack/packages/security/authorization_core/src/actions/alerting.test.ts +++ b/x-pack/packages/security/authorization_core/src/actions/alerting.test.ts @@ -51,3 +51,21 @@ describe('#get', () => { ); }); }); + +test('#isValid', () => { + const alertingActions = new AlertingActions(); + expect(alertingActions.isValid('alerting:foo-ruleType/consumer/alertingType/bar-operation')).toBe( + true + ); + + expect( + alertingActions.isValid('api:alerting:foo-ruleType/consumer/alertingType/bar-operation') + ).toBe(false); + expect(alertingActions.isValid('api:foo-ruleType/consumer/alertingType/bar-operation')).toBe( + false + ); + + expect(alertingActions.isValid('alerting_foo-ruleType/consumer/alertingType/bar-operation')).toBe( + false + ); +}); diff --git a/x-pack/packages/security/authorization_core/src/actions/alerting.ts b/x-pack/packages/security/authorization_core/src/actions/alerting.ts index c1de9a1c65d21..18abac73ef8b7 100644 --- a/x-pack/packages/security/authorization_core/src/actions/alerting.ts +++ b/x-pack/packages/security/authorization_core/src/actions/alerting.ts @@ -40,4 +40,12 @@ export class AlertingActions implements AlertingActionsType { return `${this.prefix}${ruleTypeId}/${consumer}/${alertingEntity}/${operation}`; } + + /** + * Checks if the action is a valid alerting action. + * @param action The action string to check. + */ + public isValid(action: string) { + return action.startsWith(this.prefix); + } } diff --git a/x-pack/packages/security/authorization_core/src/actions/ui.test.ts b/x-pack/packages/security/authorization_core/src/actions/ui.test.ts index 0d6419d8fd2b8..7f1c412eaa5a5 100644 --- a/x-pack/packages/security/authorization_core/src/actions/ui.test.ts +++ b/x-pack/packages/security/authorization_core/src/actions/ui.test.ts @@ -32,3 +32,15 @@ describe('#get', () => { expect(uiActions.get('foo', 'fooCapability', 'subFoo')).toBe('ui:foo/fooCapability/subFoo'); }); }); + +test('#isValid', () => { + const uiActions = new UIActions(); + expect(uiActions.isValid('ui:alpha')).toBe(true); + expect(uiActions.isValid('ui:beta')).toBe(true); + + expect(uiActions.isValid('api:alpha')).toBe(false); + expect(uiActions.isValid('api:beta')).toBe(false); + + expect(uiActions.isValid('ui_alpha')).toBe(false); + expect(uiActions.isValid('ui_beta')).toBe(false); +}); diff --git a/x-pack/packages/security/authorization_core/src/actions/ui.ts b/x-pack/packages/security/authorization_core/src/actions/ui.ts index 2c9986e1c8ce5..688bf5bbe15ee 100644 --- a/x-pack/packages/security/authorization_core/src/actions/ui.ts +++ b/x-pack/packages/security/authorization_core/src/actions/ui.ts @@ -40,4 +40,12 @@ export class UIActions implements UIActionsType { return `${this.prefix}${featureId}/${uiCapabilityParts.join('/')}`; } + + /** + * Checks if the action is a valid UI action. + * @param action The action string to check. + */ + public isValid(action: string) { + return action.startsWith(this.prefix); + } } diff --git a/x-pack/packages/security/authorization_core/src/privileges/index.ts b/x-pack/packages/security/authorization_core/src/privileges/index.ts index 7113b1b348bec..70e134751794d 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/index.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/index.ts @@ -7,5 +7,4 @@ export type { PrivilegesService } from './privileges'; export type { CasesSupportedOperations } from './feature_privilege_builder'; -export { privilegesFactory } from './privileges'; -export type { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; +export { privilegesFactory, getReplacedByForPrivilege } from './privileges'; diff --git a/x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts b/x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts index 118d63503db22..f9d490bfcb09b 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/privileges.test.ts @@ -8,7 +8,7 @@ import { KibanaFeature } from '@kbn/features-plugin/server'; import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; -import { privilegesFactory } from './privileges'; +import { getReplacedByForPrivilege, privilegesFactory } from './privileges'; import { licenseMock } from '../__fixtures__/licensing.mock'; import { Actions } from '../actions'; @@ -472,6 +472,184 @@ describe('features', () => { }); }); + test('actions should respect `replacedBy` specified by the deprecated privileges', () => { + const features: KibanaFeature[] = [ + new KibanaFeature({ + deprecated: { notice: 'It is deprecated, sorry.' }, + id: 'alpha', + name: 'Feature Alpha', + app: [], + category: { id: 'alpha', label: 'alpha' }, + alerting: ['rule-type-1'], + privileges: { + all: { + savedObject: { + all: ['all-alpha-all-so'], + read: ['all-alpha-read-so'], + }, + ui: ['all-alpha-ui'], + app: ['all-alpha-app'], + api: ['all-alpha-api'], + alerting: { rule: { all: ['rule-type-1'] } }, + replacedBy: [{ feature: 'beta', privileges: ['all'] }], + }, + read: { + savedObject: { + all: ['read-alpha-all-so'], + read: ['read-alpha-read-so'], + }, + ui: ['read-alpha-ui'], + app: ['read-alpha-app'], + api: ['read-alpha-api'], + replacedBy: { + default: [{ feature: 'beta', privileges: ['read'] }], + minimal: [{ feature: 'beta', privileges: ['minimal_read'] }], + }, + }, + }, + }), + new KibanaFeature({ + id: 'beta', + name: 'Feature Beta', + app: [], + category: { id: 'beta', label: 'beta' }, + alerting: ['rule-type-1'], + privileges: { + all: { + savedObject: { + all: ['all-beta-all-so'], + read: ['all-beta-read-so'], + }, + ui: ['all-beta-ui'], + app: ['all-beta-app'], + api: ['all-beta-api'], + alerting: { rule: { all: ['rule-type-1'] } }, + }, + read: { + savedObject: { + all: ['read-beta-all-so'], + read: ['read-beta-read-so'], + }, + ui: ['read-beta-ui'], + app: ['read-beta-app'], + api: ['read-beta-api'], + }, + }, + }), + ]; + + const mockFeaturesPlugin = featuresPluginMock.createSetup(); + mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features); + const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic); + + const alertingOperations = [ + ...[ + 'get', + 'getRuleState', + 'getAlertSummary', + 'getExecutionLog', + 'getActionErrorLog', + 'find', + 'getRuleExecutionKPI', + 'getBackfill', + 'findBackfill', + ], + ...[ + 'create', + 'delete', + 'update', + 'updateApiKey', + 'enable', + 'disable', + 'muteAll', + 'unmuteAll', + 'muteAlert', + 'unmuteAlert', + 'snooze', + 'bulkEdit', + 'bulkDelete', + 'bulkEnable', + 'bulkDisable', + 'unsnooze', + 'runSoon', + 'scheduleBackfill', + 'deleteBackfill', + ], + ]; + + const expectedAllPrivileges = [ + actions.login, + actions.api.get('all-alpha-api'), + actions.app.get('all-alpha-app'), + actions.ui.get('navLinks', 'all-alpha-app'), + actions.savedObject.get('all-alpha-all-so', 'bulk_get'), + actions.savedObject.get('all-alpha-all-so', 'get'), + actions.savedObject.get('all-alpha-all-so', 'find'), + actions.savedObject.get('all-alpha-all-so', 'open_point_in_time'), + actions.savedObject.get('all-alpha-all-so', 'close_point_in_time'), + actions.savedObject.get('all-alpha-all-so', 'create'), + actions.savedObject.get('all-alpha-all-so', 'bulk_create'), + actions.savedObject.get('all-alpha-all-so', 'update'), + actions.savedObject.get('all-alpha-all-so', 'bulk_update'), + actions.savedObject.get('all-alpha-all-so', 'delete'), + actions.savedObject.get('all-alpha-all-so', 'bulk_delete'), + actions.savedObject.get('all-alpha-all-so', 'share_to_space'), + actions.savedObject.get('all-alpha-read-so', 'bulk_get'), + actions.savedObject.get('all-alpha-read-so', 'get'), + actions.savedObject.get('all-alpha-read-so', 'find'), + actions.savedObject.get('all-alpha-read-so', 'open_point_in_time'), + actions.savedObject.get('all-alpha-read-so', 'close_point_in_time'), + actions.ui.get('alpha', 'all-alpha-ui'), + ...alertingOperations.map((operation) => + actions.alerting.get('rule-type-1', 'alpha', 'rule', operation) + ), + // To maintain compatibility with the new UI capabilities and new alerting entities that are + // feature specific: all.replacedBy: [{ feature: 'beta', privileges: ['all'] }] + actions.ui.get('navLinks', 'all-beta-app'), + actions.ui.get('beta', 'all-beta-ui'), + ...alertingOperations.map((operation) => + actions.alerting.get('rule-type-1', 'beta', 'rule', operation) + ), + ]; + + const expectedReadPrivileges = [ + actions.login, + actions.api.get('read-alpha-api'), + actions.app.get('read-alpha-app'), + actions.ui.get('navLinks', 'read-alpha-app'), + actions.savedObject.get('read-alpha-all-so', 'bulk_get'), + actions.savedObject.get('read-alpha-all-so', 'get'), + actions.savedObject.get('read-alpha-all-so', 'find'), + actions.savedObject.get('read-alpha-all-so', 'open_point_in_time'), + actions.savedObject.get('read-alpha-all-so', 'close_point_in_time'), + actions.savedObject.get('read-alpha-all-so', 'create'), + actions.savedObject.get('read-alpha-all-so', 'bulk_create'), + actions.savedObject.get('read-alpha-all-so', 'update'), + actions.savedObject.get('read-alpha-all-so', 'bulk_update'), + actions.savedObject.get('read-alpha-all-so', 'delete'), + actions.savedObject.get('read-alpha-all-so', 'bulk_delete'), + actions.savedObject.get('read-alpha-all-so', 'share_to_space'), + actions.savedObject.get('read-alpha-read-so', 'bulk_get'), + actions.savedObject.get('read-alpha-read-so', 'get'), + actions.savedObject.get('read-alpha-read-so', 'find'), + actions.savedObject.get('read-alpha-read-so', 'open_point_in_time'), + actions.savedObject.get('read-alpha-read-so', 'close_point_in_time'), + actions.ui.get('alpha', 'read-alpha-ui'), + // To maintain compatibility with the new UI capabilities that are feature specific + // read.replacedBy: [{ feature: 'beta', privileges: ['read'] }] + actions.ui.get('navLinks', 'read-beta-app'), + actions.ui.get('beta', 'read-beta-ui'), + ]; + + const actual = privileges.get(); + expect(actual).toHaveProperty('features.alpha', { + all: [...expectedAllPrivileges], + read: [...expectedReadPrivileges], + minimal_all: [...expectedAllPrivileges], + minimal_read: [...expectedReadPrivileges], + }); + }); + test(`features with no privileges aren't listed`, () => { const features: KibanaFeature[] = [ new KibanaFeature({ @@ -3510,4 +3688,360 @@ describe('subFeatures', () => { ]); }); }); + + test('actions should respect `replacedBy` specified by the deprecated sub-feature privileges', () => { + const features: KibanaFeature[] = [ + new KibanaFeature({ + deprecated: { notice: 'It is deprecated, sorry.' }, + id: 'alpha', + name: 'Feature Alpha', + app: [], + category: { id: 'alpha', label: 'alpha' }, + privileges: { + all: { + savedObject: { + all: ['all-alpha-all-so'], + read: ['all-alpha-read-so'], + }, + ui: ['all-alpha-ui'], + app: ['all-alpha-app'], + api: ['all-alpha-api'], + replacedBy: [{ feature: 'beta', privileges: ['all'] }], + }, + read: { + savedObject: { + all: ['read-alpha-all-so'], + read: ['read-alpha-read-so'], + }, + ui: ['read-alpha-ui'], + app: ['read-alpha-app'], + api: ['read-alpha-api'], + replacedBy: { + default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }], + minimal: [{ feature: 'beta', privileges: ['minimal_read'] }], + }, + }, + }, + subFeatures: [ + { + name: 'sub-feature-alpha', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_alpha', + name: 'Sub Feature Alpha', + includeIn: 'all', + savedObject: { + all: ['sub-alpha-all-so'], + read: ['sub-alpha-read-so'], + }, + ui: ['sub-alpha-ui'], + app: ['sub-alpha-app'], + api: ['sub-alpha-api'], + replacedBy: [ + { feature: 'beta', privileges: ['minimal_read'] }, + { feature: 'beta', privileges: ['sub_beta'] }, + ], + }, + ], + }, + ], + }, + ], + }), + new KibanaFeature({ + id: 'beta', + name: 'Feature Beta', + app: [], + category: { id: 'beta', label: 'beta' }, + privileges: { + all: { + savedObject: { + all: ['all-beta-all-so'], + read: ['all-beta-read-so'], + }, + ui: ['all-beta-ui'], + app: ['all-beta-app'], + api: ['all-beta-api'], + }, + read: { + savedObject: { + all: ['read-beta-all-so'], + read: ['read-beta-read-so'], + }, + ui: ['read-beta-ui'], + app: ['read-beta-app'], + api: ['read-beta-api'], + }, + }, + subFeatures: [ + { + name: 'sub-feature-beta', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_beta', + name: 'Sub Feature Beta', + includeIn: 'all', + savedObject: { + all: ['sub-beta-all-so'], + read: ['sub-beta-read-so'], + }, + ui: ['sub-beta-ui'], + app: ['sub-beta-app'], + api: ['sub-beta-api'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockFeaturesPlugin = featuresPluginMock.createSetup(); + mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features); + const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceGold); + + const expectedAllPrivileges = [ + actions.login, + actions.api.get('all-alpha-api'), + actions.api.get('sub-alpha-api'), + actions.app.get('all-alpha-app'), + actions.app.get('sub-alpha-app'), + actions.ui.get('navLinks', 'all-alpha-app'), + actions.ui.get('navLinks', 'sub-alpha-app'), + actions.savedObject.get('all-alpha-all-so', 'bulk_get'), + actions.savedObject.get('all-alpha-all-so', 'get'), + actions.savedObject.get('all-alpha-all-so', 'find'), + actions.savedObject.get('all-alpha-all-so', 'open_point_in_time'), + actions.savedObject.get('all-alpha-all-so', 'close_point_in_time'), + actions.savedObject.get('all-alpha-all-so', 'create'), + actions.savedObject.get('all-alpha-all-so', 'bulk_create'), + actions.savedObject.get('all-alpha-all-so', 'update'), + actions.savedObject.get('all-alpha-all-so', 'bulk_update'), + actions.savedObject.get('all-alpha-all-so', 'delete'), + actions.savedObject.get('all-alpha-all-so', 'bulk_delete'), + actions.savedObject.get('all-alpha-all-so', 'share_to_space'), + actions.savedObject.get('sub-alpha-all-so', 'bulk_get'), + actions.savedObject.get('sub-alpha-all-so', 'get'), + actions.savedObject.get('sub-alpha-all-so', 'find'), + actions.savedObject.get('sub-alpha-all-so', 'open_point_in_time'), + actions.savedObject.get('sub-alpha-all-so', 'close_point_in_time'), + actions.savedObject.get('sub-alpha-all-so', 'create'), + actions.savedObject.get('sub-alpha-all-so', 'bulk_create'), + actions.savedObject.get('sub-alpha-all-so', 'update'), + actions.savedObject.get('sub-alpha-all-so', 'bulk_update'), + actions.savedObject.get('sub-alpha-all-so', 'delete'), + actions.savedObject.get('sub-alpha-all-so', 'bulk_delete'), + actions.savedObject.get('sub-alpha-all-so', 'share_to_space'), + actions.savedObject.get('all-alpha-read-so', 'bulk_get'), + actions.savedObject.get('all-alpha-read-so', 'get'), + actions.savedObject.get('all-alpha-read-so', 'find'), + actions.savedObject.get('all-alpha-read-so', 'open_point_in_time'), + actions.savedObject.get('all-alpha-read-so', 'close_point_in_time'), + actions.savedObject.get('sub-alpha-read-so', 'bulk_get'), + actions.savedObject.get('sub-alpha-read-so', 'get'), + actions.savedObject.get('sub-alpha-read-so', 'find'), + actions.savedObject.get('sub-alpha-read-so', 'open_point_in_time'), + actions.savedObject.get('sub-alpha-read-so', 'close_point_in_time'), + actions.ui.get('alpha', 'all-alpha-ui'), + actions.ui.get('alpha', 'sub-alpha-ui'), + // To maintain compatibility with the new UI capabilities that are feature specific: + // all.replacedBy: [{ feature: 'beta', privileges: ['all'] }], + actions.ui.get('navLinks', 'all-beta-app'), + actions.ui.get('navLinks', 'sub-beta-app'), + actions.ui.get('beta', 'all-beta-ui'), + actions.ui.get('beta', 'sub-beta-ui'), + ]; + + const expectedMinimalAllPrivileges = [ + actions.login, + actions.api.get('all-alpha-api'), + actions.app.get('all-alpha-app'), + actions.ui.get('navLinks', 'all-alpha-app'), + actions.savedObject.get('all-alpha-all-so', 'bulk_get'), + actions.savedObject.get('all-alpha-all-so', 'get'), + actions.savedObject.get('all-alpha-all-so', 'find'), + actions.savedObject.get('all-alpha-all-so', 'open_point_in_time'), + actions.savedObject.get('all-alpha-all-so', 'close_point_in_time'), + actions.savedObject.get('all-alpha-all-so', 'create'), + actions.savedObject.get('all-alpha-all-so', 'bulk_create'), + actions.savedObject.get('all-alpha-all-so', 'update'), + actions.savedObject.get('all-alpha-all-so', 'bulk_update'), + actions.savedObject.get('all-alpha-all-so', 'delete'), + actions.savedObject.get('all-alpha-all-so', 'bulk_delete'), + actions.savedObject.get('all-alpha-all-so', 'share_to_space'), + actions.savedObject.get('all-alpha-read-so', 'bulk_get'), + actions.savedObject.get('all-alpha-read-so', 'get'), + actions.savedObject.get('all-alpha-read-so', 'find'), + actions.savedObject.get('all-alpha-read-so', 'open_point_in_time'), + actions.savedObject.get('all-alpha-read-so', 'close_point_in_time'), + actions.ui.get('alpha', 'all-alpha-ui'), + // To maintain compatibility with the new UI capabilities that are feature specific. + // Actions from the beta feature top-level and sub-feature privileges are included because + // used simple `replacedBy` format: + // all.replacedBy: [{ feature: 'beta', privileges: ['all'] }], + actions.ui.get('navLinks', 'all-beta-app'), + actions.ui.get('navLinks', 'sub-beta-app'), + actions.ui.get('beta', 'all-beta-ui'), + actions.ui.get('beta', 'sub-beta-ui'), + ]; + + const expectedReadPrivileges = [ + actions.login, + actions.api.get('read-alpha-api'), + actions.app.get('read-alpha-app'), + actions.ui.get('navLinks', 'read-alpha-app'), + actions.savedObject.get('read-alpha-all-so', 'bulk_get'), + actions.savedObject.get('read-alpha-all-so', 'get'), + actions.savedObject.get('read-alpha-all-so', 'find'), + actions.savedObject.get('read-alpha-all-so', 'open_point_in_time'), + actions.savedObject.get('read-alpha-all-so', 'close_point_in_time'), + actions.savedObject.get('read-alpha-all-so', 'create'), + actions.savedObject.get('read-alpha-all-so', 'bulk_create'), + actions.savedObject.get('read-alpha-all-so', 'update'), + actions.savedObject.get('read-alpha-all-so', 'bulk_update'), + actions.savedObject.get('read-alpha-all-so', 'delete'), + actions.savedObject.get('read-alpha-all-so', 'bulk_delete'), + actions.savedObject.get('read-alpha-all-so', 'share_to_space'), + actions.savedObject.get('read-alpha-read-so', 'bulk_get'), + actions.savedObject.get('read-alpha-read-so', 'get'), + actions.savedObject.get('read-alpha-read-so', 'find'), + actions.savedObject.get('read-alpha-read-so', 'open_point_in_time'), + actions.savedObject.get('read-alpha-read-so', 'close_point_in_time'), + actions.ui.get('alpha', 'read-alpha-ui'), + // To maintain compatibility with the new UI capabilities that are feature specific: + // read.replacedBy: { + // default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }] + // }, + actions.ui.get('navLinks', 'read-beta-app'), + actions.ui.get('beta', 'read-beta-ui'), + actions.ui.get('navLinks', 'sub-beta-app'), + actions.ui.get('beta', 'sub-beta-ui'), + ]; + + const expectedMinimalReadPrivileges = [ + actions.login, + actions.api.get('read-alpha-api'), + actions.app.get('read-alpha-app'), + actions.ui.get('navLinks', 'read-alpha-app'), + actions.savedObject.get('read-alpha-all-so', 'bulk_get'), + actions.savedObject.get('read-alpha-all-so', 'get'), + actions.savedObject.get('read-alpha-all-so', 'find'), + actions.savedObject.get('read-alpha-all-so', 'open_point_in_time'), + actions.savedObject.get('read-alpha-all-so', 'close_point_in_time'), + actions.savedObject.get('read-alpha-all-so', 'create'), + actions.savedObject.get('read-alpha-all-so', 'bulk_create'), + actions.savedObject.get('read-alpha-all-so', 'update'), + actions.savedObject.get('read-alpha-all-so', 'bulk_update'), + actions.savedObject.get('read-alpha-all-so', 'delete'), + actions.savedObject.get('read-alpha-all-so', 'bulk_delete'), + actions.savedObject.get('read-alpha-all-so', 'share_to_space'), + actions.savedObject.get('read-alpha-read-so', 'bulk_get'), + actions.savedObject.get('read-alpha-read-so', 'get'), + actions.savedObject.get('read-alpha-read-so', 'find'), + actions.savedObject.get('read-alpha-read-so', 'open_point_in_time'), + actions.savedObject.get('read-alpha-read-so', 'close_point_in_time'), + actions.ui.get('alpha', 'read-alpha-ui'), + // To maintain compatibility with the new UI capabilities that are feature specific: + // read.replacedBy: { + // minimal: [{ feature: 'beta', privileges: ['minimal_read'] }], + // }, + actions.ui.get('navLinks', 'read-beta-app'), + actions.ui.get('beta', 'read-beta-ui'), + ]; + + const expectedSubFeaturePrivileges = [ + actions.login, + actions.api.get('sub-alpha-api'), + actions.app.get('sub-alpha-app'), + actions.ui.get('navLinks', 'sub-alpha-app'), + actions.savedObject.get('sub-alpha-all-so', 'bulk_get'), + actions.savedObject.get('sub-alpha-all-so', 'get'), + actions.savedObject.get('sub-alpha-all-so', 'find'), + actions.savedObject.get('sub-alpha-all-so', 'open_point_in_time'), + actions.savedObject.get('sub-alpha-all-so', 'close_point_in_time'), + actions.savedObject.get('sub-alpha-all-so', 'create'), + actions.savedObject.get('sub-alpha-all-so', 'bulk_create'), + actions.savedObject.get('sub-alpha-all-so', 'update'), + actions.savedObject.get('sub-alpha-all-so', 'bulk_update'), + actions.savedObject.get('sub-alpha-all-so', 'delete'), + actions.savedObject.get('sub-alpha-all-so', 'bulk_delete'), + actions.savedObject.get('sub-alpha-all-so', 'share_to_space'), + actions.savedObject.get('sub-alpha-read-so', 'bulk_get'), + actions.savedObject.get('sub-alpha-read-so', 'get'), + actions.savedObject.get('sub-alpha-read-so', 'find'), + actions.savedObject.get('sub-alpha-read-so', 'open_point_in_time'), + actions.savedObject.get('sub-alpha-read-so', 'close_point_in_time'), + actions.ui.get('alpha', 'sub-alpha-ui'), + // To maintain compatibility with the new UI capabilities that are feature specific: + // sub_alpha.replacedBy: [ + // { feature: 'beta', privileges: ['minimal_read'] }, + // { feature: 'beta', privileges: ['sub_beta'] }, + // ], + actions.ui.get('navLinks', 'read-beta-app'), + actions.ui.get('beta', 'read-beta-ui'), + actions.ui.get('navLinks', 'sub-beta-app'), + actions.ui.get('beta', 'sub-beta-ui'), + ]; + + const actual = privileges.get(); + expect(actual).toHaveProperty('features.alpha', { + all: expectedAllPrivileges, + read: expectedReadPrivileges, + minimal_all: expectedMinimalAllPrivileges, + minimal_read: expectedMinimalReadPrivileges, + sub_alpha: expectedSubFeaturePrivileges, + }); + }); +}); + +describe('#getReplacedByForPrivilege', () => { + test('correctly gets `replacedBy` with simple format', () => { + const basePrivilege = { savedObject: { all: [], read: [] }, ui: [] }; + expect(getReplacedByForPrivilege('all', basePrivilege)).toBeUndefined(); + expect(getReplacedByForPrivilege('minimal_all', basePrivilege)).toBeUndefined(); + + const privilegeWithReplacedBy = { + ...basePrivilege, + replacedBy: [{ feature: 'alpha', privileges: ['all', 'read'] }], + }; + expect(getReplacedByForPrivilege('all', privilegeWithReplacedBy)).toEqual([ + { feature: 'alpha', privileges: ['all', 'read'] }, + ]); + expect(getReplacedByForPrivilege('minimal_all', privilegeWithReplacedBy)).toEqual([ + { feature: 'alpha', privileges: ['all', 'read'] }, + ]); + expect(getReplacedByForPrivilege('custom', privilegeWithReplacedBy)).toEqual([ + { feature: 'alpha', privileges: ['all', 'read'] }, + ]); + }); + + test('correctly gets `replacedBy` with extended format', () => { + const basePrivilege = { savedObject: { all: [], read: [] }, ui: [] }; + expect(getReplacedByForPrivilege('all', basePrivilege)).toBeUndefined(); + expect(getReplacedByForPrivilege('minimal_all', basePrivilege)).toBeUndefined(); + + const privilegeWithReplacedBy = { + ...basePrivilege, + replacedBy: { + default: [{ feature: 'alpha', privileges: ['all', 'read', 'custom'] }], + minimal: [{ feature: 'alpha', privileges: ['minimal_all'] }], + }, + }; + expect(getReplacedByForPrivilege('all', privilegeWithReplacedBy)).toEqual([ + { feature: 'alpha', privileges: ['all', 'read', 'custom'] }, + ]); + expect(getReplacedByForPrivilege('custom', privilegeWithReplacedBy)).toEqual([ + { feature: 'alpha', privileges: ['all', 'read', 'custom'] }, + ]); + expect(getReplacedByForPrivilege('minimal_all', privilegeWithReplacedBy)).toEqual([ + { feature: 'alpha', privileges: ['minimal_all'] }, + ]); + }); }); diff --git a/x-pack/packages/security/authorization_core/src/privileges/privileges.ts b/x-pack/packages/security/authorization_core/src/privileges/privileges.ts index 6b8acc4e4013a..7f388e80defd2 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/privileges.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/privileges.ts @@ -12,10 +12,13 @@ import type { FeatureKibanaPrivilegesReference, } from '@kbn/features-plugin/common'; import type { FeaturesPluginSetup, KibanaFeature } from '@kbn/features-plugin/server'; -import type { SecurityLicense } from '@kbn/security-plugin-types-common'; +import { + getMinimalPrivilegeId, + isMinimalPrivilegeId, +} from '@kbn/security-authorization-core-common'; +import type { RawKibanaPrivileges, SecurityLicense } from '@kbn/security-plugin-types-common'; import { featurePrivilegeBuilderFactory } from './feature_privilege_builder'; -import type { RawKibanaPrivileges } from './raw_kibana_privileges'; import type { Actions } from '../actions'; export interface PrivilegesService { @@ -63,26 +66,46 @@ export function privilegesFactory( // Remember privilege as composable to update it later, once actions for all referenced privileges are also // calculated and registered. - const composableFeaturePrivileges: Array<{ + const composablePrivileges: Array<{ featureId: string; privilegeId: string; + references: readonly FeatureKibanaPrivilegesReference[]; excludeFromBasePrivileges?: boolean; - composedOf: readonly FeatureKibanaPrivilegesReference[]; + actionsFilter?: (action: string) => boolean; }> = []; - const tryStoreComposableFeature = ( + const tryStoreComposablePrivilege = ( feature: KibanaFeature, privilegeId: string, privilege: FeatureKibanaPrivileges ) => { + // If privilege is configured with `composedOf` it should be complemented with **all** + // actions from referenced privileges. if (privilege.composedOf) { - composableFeaturePrivileges.push({ + composablePrivileges.push({ featureId: feature.id, privilegeId, - composedOf: privilege.composedOf, + references: privilege.composedOf, excludeFromBasePrivileges: feature.excludeFromBasePrivileges || privilege.excludeFromBasePrivileges, }); } + + // If a privilege is configured with `replacedBy`, it's part of the deprecated feature and + // should be complemented with the subset of actions from the referenced privileges to + // maintain backward compatibility. Namely, deprecated privileges should grant the same UI + // capabilities and alerting actions as the privileges that replace them, so that the + // client-side code can safely use only non-deprecated UI capabilities and users can still + // access previously created alerting rules and alerts. + const replacedBy = getReplacedByForPrivilege(privilegeId, privilege); + if (replacedBy) { + composablePrivileges.push({ + featureId: feature.id, + privilegeId, + references: replacedBy, + actionsFilter: (action) => + actions.ui.isValid(action) || actions.alerting.isValid(action), + }); + } }; const hiddenFeatures = new Set(); @@ -99,20 +122,20 @@ export function privilegesFactory( ...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)), ]; - tryStoreComposableFeature(feature, fullPrivilegeId, featurePrivilege.privilege); + tryStoreComposablePrivilege(feature, fullPrivilegeId, featurePrivilege.privilege); } for (const featurePrivilege of featuresService.featurePrivilegeIterator(feature, { augmentWithSubFeaturePrivileges: false, licenseHasAtLeast, })) { - const minimalPrivilegeId = `minimal_${featurePrivilege.privilegeId}`; + const minimalPrivilegeId = getMinimalPrivilegeId(featurePrivilege.privilegeId); featurePrivileges[feature.id][minimalPrivilegeId] = [ actions.login, ...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)), ]; - tryStoreComposableFeature(feature, minimalPrivilegeId, featurePrivilege.privilege); + tryStoreComposablePrivilege(feature, minimalPrivilegeId, featurePrivilege.privilege); } if ( @@ -127,6 +150,8 @@ export function privilegesFactory( actions.login, ...uniq(featurePrivilegeBuilder.getActions(subFeaturePrivilege, feature)), ]; + + tryStoreComposablePrivilege(feature, subFeaturePrivilege.id, subFeaturePrivilege); } } @@ -141,11 +166,14 @@ export function privilegesFactory( // another feature. This could potentially enable functionality in a license lower than originally intended. It // might or might not be desired, but we're accepting this for now, as every attempt to compose a feature // undergoes a stringent review process. - for (const composableFeature of composableFeaturePrivileges) { - const composedActions = composableFeature.composedOf.flatMap((privilegeReference) => - privilegeReference.privileges.flatMap( - (privilege) => featurePrivileges[privilegeReference.feature][privilege] - ) + for (const composableFeature of composablePrivileges) { + const composedActions = composableFeature.references.flatMap((privilegeReference) => + privilegeReference.privileges.flatMap((privilege) => { + const privilegeActions = featurePrivileges[privilegeReference.feature][privilege] ?? []; + return composableFeature.actionsFilter + ? privilegeActions.filter(composableFeature.actionsFilter) + : privilegeActions; + }) ); featurePrivileges[composableFeature.featureId][composableFeature.privilegeId] = [ ...new Set( @@ -220,3 +248,27 @@ export function privilegesFactory( }, }; } + +/** + * Returns a list of privileges that replace the given privilege, if any. Works for both top-level + * and sub-feature privileges. + * @param privilegeId The ID of the privilege to get replacements for. + * @param privilege The privilege definition to get replacements for. + */ +export function getReplacedByForPrivilege( + privilegeId: string, + privilege: FeatureKibanaPrivileges +): readonly FeatureKibanaPrivilegesReference[] | undefined { + const replacedBy = privilege.replacedBy; + if (!replacedBy) { + return; + } + + // If a privilege of the deprecated feature explicitly defines a replacement for minimal privileges, use it. + // Otherwise, use the default replacement for all cases. + return 'minimal' in replacedBy + ? isMinimalPrivilegeId(privilegeId) + ? replacedBy.minimal + : replacedBy.default + : replacedBy; +} diff --git a/x-pack/packages/security/authorization_core/tsconfig.json b/x-pack/packages/security/authorization_core/tsconfig.json index 03870180c12c5..08437d11e23b9 100644 --- a/x-pack/packages/security/authorization_core/tsconfig.json +++ b/x-pack/packages/security/authorization_core/tsconfig.json @@ -9,6 +9,7 @@ "kbn_references": [ "@kbn/core", "@kbn/features-plugin", + "@kbn/security-authorization-core-common", "@kbn/security-plugin-types-common", "@kbn/security-plugin-types-server", "@kbn/licensing-plugin", diff --git a/x-pack/packages/security/authorization_core_common/README.md b/x-pack/packages/security/authorization_core_common/README.md new file mode 100644 index 0000000000000..1b4259d658730 --- /dev/null +++ b/x-pack/packages/security/authorization_core_common/README.md @@ -0,0 +1,3 @@ +# @kbn/security-authorization-core-common + +Contains core authorization logic (shared between server and browser) diff --git a/x-pack/packages/security/authorization_core_common/index.ts b/x-pack/packages/security/authorization_core_common/index.ts new file mode 100644 index 0000000000000..63fa40559fae2 --- /dev/null +++ b/x-pack/packages/security/authorization_core_common/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { isMinimalPrivilegeId, getMinimalPrivilegeId } from './src/privileges'; diff --git a/x-pack/packages/security/authorization_core_common/jest.config.js b/x-pack/packages/security/authorization_core_common/jest.config.js new file mode 100644 index 0000000000000..1034836296ba1 --- /dev/null +++ b/x-pack/packages/security/authorization_core_common/jest.config.js @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + coverageDirectory: '/x-pack/packages/security/authorization_core_common', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/packages/security/authorization_core_common/**/*.{ts,tsx}', + ], + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/x-pack/packages/security/authorization_core_common'], +}; diff --git a/x-pack/packages/security/authorization_core_common/kibana.jsonc b/x-pack/packages/security/authorization_core_common/kibana.jsonc new file mode 100644 index 0000000000000..1ddb58d875826 --- /dev/null +++ b/x-pack/packages/security/authorization_core_common/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/security-authorization-core-common", + "owner": "@elastic/kibana-security" +} diff --git a/x-pack/packages/security/authorization_core_common/package.json b/x-pack/packages/security/authorization_core_common/package.json new file mode 100644 index 0000000000000..74811f44978d7 --- /dev/null +++ b/x-pack/packages/security/authorization_core_common/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/security-authorization-core-common", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} diff --git a/x-pack/packages/security/authorization_core_common/src/privileges/index.ts b/x-pack/packages/security/authorization_core_common/src/privileges/index.ts new file mode 100644 index 0000000000000..01e05bfabde5c --- /dev/null +++ b/x-pack/packages/security/authorization_core_common/src/privileges/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { isMinimalPrivilegeId, getMinimalPrivilegeId } from './minimal_privileges'; diff --git a/x-pack/packages/security/authorization_core_common/src/privileges/minimal_privileges.test.ts b/x-pack/packages/security/authorization_core_common/src/privileges/minimal_privileges.test.ts new file mode 100644 index 0000000000000..cbec8e7f96796 --- /dev/null +++ b/x-pack/packages/security/authorization_core_common/src/privileges/minimal_privileges.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getMinimalPrivilegeId, isMinimalPrivilegeId } from '../..'; + +describe('Minimal privileges', () => { + it('#isMinimalPrivilegeId correctly detects minimal privileges', () => { + expect(isMinimalPrivilegeId('minimal_all')).toBe(true); + expect(isMinimalPrivilegeId('minimal_read')).toBe(true); + + for (const privilege of ['all', 'read', 'none', 'custom', 'minimal_custom', 'minimal_none']) { + expect(isMinimalPrivilegeId(privilege)).toBe(false); + } + }); + + it('#getMinimalPrivilegeId correctly constructs minimal privilege ID', () => { + expect(getMinimalPrivilegeId('all')).toBe('minimal_all'); + expect(getMinimalPrivilegeId('minimal_all')).toBe('minimal_all'); + + expect(getMinimalPrivilegeId('read')).toBe('minimal_read'); + expect(getMinimalPrivilegeId('minimal_read')).toBe('minimal_read'); + + expect(() => getMinimalPrivilegeId('none')).toThrowErrorMatchingInlineSnapshot( + `"Minimal privileges are only available for \\"read\\" and \\"all\\" privileges, but \\"none\\" was provided."` + ); + expect(() => getMinimalPrivilegeId('custom')).toThrowErrorMatchingInlineSnapshot( + `"Minimal privileges are only available for \\"read\\" and \\"all\\" privileges, but \\"custom\\" was provided."` + ); + expect(() => getMinimalPrivilegeId('minimal_none')).toThrowErrorMatchingInlineSnapshot( + `"Minimal privileges are only available for \\"read\\" and \\"all\\" privileges, but \\"minimal_none\\" was provided."` + ); + expect(() => getMinimalPrivilegeId('minimal_custom')).toThrowErrorMatchingInlineSnapshot( + `"Minimal privileges are only available for \\"read\\" and \\"all\\" privileges, but \\"minimal_custom\\" was provided."` + ); + }); +}); diff --git a/x-pack/packages/security/authorization_core_common/src/privileges/minimal_privileges.ts b/x-pack/packages/security/authorization_core_common/src/privileges/minimal_privileges.ts new file mode 100644 index 0000000000000..e3484bc8a3890 --- /dev/null +++ b/x-pack/packages/security/authorization_core_common/src/privileges/minimal_privileges.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Minimal privileges only exist for top-level privileges, as "minimal" means a privilege without + * any associated sub-feature privileges. Currently, sub-feature privileges cannot include or be + * associated with other sub-feature privileges. We use "minimal" privileges under the hood when + * admins customize sub-feature privileges for a given top-level privilege. We have only + * `minimal_all` and `minimal_read` minimal privileges. + * + * For example, let’s assume we have a feature Alpha with `All` and `Read` top-level privileges, and + * `Sub-alpha-1` and `Sub-alpha-2` sub-feature privileges, which are **by default included** in the + * `All` top-level privilege. When an admin toggles the `All` privilege for feature Alpha and + * doesn’t change anything else, the resulting role will only have the `feature-alpha.all` + * privilege, which assumes/includes both `sub-alpha-1` and `sub-alpha-2`. However, if the admin + * decides to customize sub-feature privileges and toggles off `Sub-alpha-2`, the resulting role + * will include `feature-alpha.minimal_all` and `feature-alpha.sub-alpha-1` thus excluding + * `feature-alpha.sub-alpha-2` that's included in `feature-alpha.all`, but not in + * `feature-alpha.minimal_all`. + */ + +/** + * Returns true if the given privilege ID is a minimal feature privilege. + * @param privilegeId The privilege ID to check. + */ +export function isMinimalPrivilegeId(privilegeId: string) { + return privilegeId === 'minimal_all' || privilegeId === 'minimal_read'; +} + +/** + * Returns the minimal privilege ID for the given privilege ID. + * @param privilegeId The privilege ID to get the minimal privilege ID for. Only `all` and `read` + * privileges have "minimal" equivalents. + */ +export function getMinimalPrivilegeId(privilegeId: string) { + if (isMinimalPrivilegeId(privilegeId)) { + return privilegeId; + } + + if (privilegeId !== 'read' && privilegeId !== 'all') { + throw new Error( + `Minimal privileges are only available for "read" and "all" privileges, but "${privilegeId}" was provided.` + ); + } + + return `minimal_${privilegeId}`; +} diff --git a/x-pack/packages/security/authorization_core_common/tsconfig.json b/x-pack/packages/security/authorization_core_common/tsconfig.json new file mode 100644 index 0000000000000..e8a4b1a87df85 --- /dev/null +++ b/x-pack/packages/security/authorization_core_common/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": ["jest", "node", "react"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["target/**/*"], + "kbn_references": [] +} diff --git a/x-pack/packages/security/plugin_types_common/index.ts b/x-pack/packages/security/plugin_types_common/index.ts index 8dd0ff726103a..840e32a77b9c1 100644 --- a/x-pack/packages/security/plugin_types_common/index.ts +++ b/x-pack/packages/security/plugin_types_common/index.ts @@ -18,6 +18,8 @@ export type { RoleRemoteIndexPrivilege, RoleRemoteClusterPrivilege, FeaturesPrivileges, + RawKibanaFeaturePrivileges, + RawKibanaPrivileges, } from './src/authorization'; export type { SecurityLicense, SecurityLicenseFeatures, LoginLayout } from './src/licensing'; export type { diff --git a/x-pack/packages/security/plugin_types_common/src/authorization/index.ts b/x-pack/packages/security/plugin_types_common/src/authorization/index.ts index 89857a18865af..2a4462960b376 100644 --- a/x-pack/packages/security/plugin_types_common/src/authorization/index.ts +++ b/x-pack/packages/security/plugin_types_common/src/authorization/index.ts @@ -6,6 +6,7 @@ */ export type { FeaturesPrivileges } from './features_privileges'; +export type { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from './raw_kibana_privileges'; export type { Role, RoleKibanaPrivilege, diff --git a/x-pack/packages/security/authorization_core/src/privileges/raw_kibana_privileges.ts b/x-pack/packages/security/plugin_types_common/src/authorization/raw_kibana_privileges.ts similarity index 100% rename from x-pack/packages/security/authorization_core/src/privileges/raw_kibana_privileges.ts rename to x-pack/packages/security/plugin_types_common/src/authorization/raw_kibana_privileges.ts diff --git a/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts b/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts index 25d768cb7b1ac..4069fa574a2a0 100644 --- a/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts +++ b/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { RawKibanaPrivileges } from '@kbn/security-authorization-core'; +import type { RawKibanaPrivileges } from '@kbn/security-plugin-types-common'; export interface PrivilegesAPIClientGetAllArgs { includeActions: boolean; diff --git a/x-pack/packages/security/plugin_types_public/tsconfig.json b/x-pack/packages/security/plugin_types_public/tsconfig.json index 5c97e25656ecf..6779851e86367 100644 --- a/x-pack/packages/security/plugin_types_public/tsconfig.json +++ b/x-pack/packages/security/plugin_types_public/tsconfig.json @@ -14,6 +14,5 @@ "@kbn/core-user-profile-common", "@kbn/security-plugin-types-common", "@kbn/core-security-common", - "@kbn/security-authorization-core" ] } diff --git a/x-pack/packages/security/role_management_model/src/kibana_privileges.ts b/x-pack/packages/security/role_management_model/src/kibana_privileges.ts index a54ee72cf308a..ca4033047725a 100644 --- a/x-pack/packages/security/role_management_model/src/kibana_privileges.ts +++ b/x-pack/packages/security/role_management_model/src/kibana_privileges.ts @@ -6,8 +6,7 @@ */ import type { KibanaFeature } from '@kbn/features-plugin/common'; -import type { RawKibanaPrivileges } from '@kbn/security-authorization-core'; -import type { RoleKibanaPrivilege } from '@kbn/security-plugin-types-common'; +import type { RawKibanaPrivileges, RoleKibanaPrivilege } from '@kbn/security-plugin-types-common'; import { KibanaPrivilege } from './kibana_privilege'; import { PrivilegeCollection } from './privilege_collection'; diff --git a/x-pack/packages/security/role_management_model/src/primary_feature_privilege.ts b/x-pack/packages/security/role_management_model/src/primary_feature_privilege.ts index f9513c8ebb4d3..5fa03f40fbc8d 100644 --- a/x-pack/packages/security/role_management_model/src/primary_feature_privilege.ts +++ b/x-pack/packages/security/role_management_model/src/primary_feature_privilege.ts @@ -6,6 +6,7 @@ */ import type { FeatureKibanaPrivileges } from '@kbn/features-plugin/public'; +import { getMinimalPrivilegeId } from '@kbn/security-authorization-core-common'; import { KibanaPrivilege } from './kibana_privilege'; @@ -18,15 +19,8 @@ export class PrimaryFeaturePrivilege extends KibanaPrivilege { super(id, actions); } - public isMinimalFeaturePrivilege() { - return this.id.startsWith('minimal_'); - } - public getMinimalPrivilegeId() { - if (this.isMinimalFeaturePrivilege()) { - return this.id; - } - return `minimal_${this.id}`; + return getMinimalPrivilegeId(this.id); } public get requireAllSpaces() { diff --git a/x-pack/packages/security/role_management_model/src/secured_feature.ts b/x-pack/packages/security/role_management_model/src/secured_feature.ts index d11b45129e6f9..f8ae1298679a2 100644 --- a/x-pack/packages/security/role_management_model/src/secured_feature.ts +++ b/x-pack/packages/security/role_management_model/src/secured_feature.ts @@ -7,6 +7,7 @@ import type { KibanaFeatureConfig } from '@kbn/features-plugin/common'; import { KibanaFeature } from '@kbn/features-plugin/common'; +import { getMinimalPrivilegeId } from '@kbn/security-authorization-core-common'; import { PrimaryFeaturePrivilege } from './primary_feature_privilege'; import { SecuredSubFeature } from './secured_sub_feature'; @@ -31,8 +32,14 @@ export class SecuredFeature extends KibanaFeature { ); this.minimalPrimaryFeaturePrivileges = Object.entries(this.config.privileges || {}).map( - ([id, privilege]) => - new PrimaryFeaturePrivilege(`minimal_${id}`, privilege, actionMapping[`minimal_${id}`]) + ([id, privilege]) => { + const minimalPrivilegeId = getMinimalPrivilegeId(id); + return new PrimaryFeaturePrivilege( + minimalPrivilegeId, + privilege, + actionMapping[minimalPrivilegeId] + ); + } ); this.securedSubFeatures = diff --git a/x-pack/packages/security/role_management_model/tsconfig.json b/x-pack/packages/security/role_management_model/tsconfig.json index f18ed64fae713..026bde0ceaa11 100644 --- a/x-pack/packages/security/role_management_model/tsconfig.json +++ b/x-pack/packages/security/role_management_model/tsconfig.json @@ -10,6 +10,7 @@ "@kbn/features-plugin", "@kbn/security-plugin-types-common", "@kbn/security-authorization-core", + "@kbn/security-authorization-core-common", "@kbn/licensing-plugin", ] } diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index 54913e08223c5..188fade8dd2cb 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -272,4 +272,17 @@ export interface FeatureKibanaPrivileges { * grant. This property can only be set in the feature configuration overrides. */ composedOf?: readonly FeatureKibanaPrivilegesReference[]; + + /** + * An optional list of other registered feature or sub-feature privileges that, when combined, grant equivalent access + * if the feature this privilege belongs to becomes deprecated. The extended definition allows separate lists of + * privileges to be defined for the default and minimal (excludes any automatically granted sub-feature privileges) + * sets. This property can only be set if the feature is marked as deprecated. + */ + replacedBy?: + | readonly FeatureKibanaPrivilegesReference[] + | { + default: readonly FeatureKibanaPrivilegesReference[]; + minimal: readonly FeatureKibanaPrivilegesReference[]; + }; } diff --git a/x-pack/plugins/features/common/kibana_feature.ts b/x-pack/plugins/features/common/kibana_feature.ts index bafa0329d359d..3a5d9fc2a0e50 100644 --- a/x-pack/plugins/features/common/kibana_feature.ts +++ b/x-pack/plugins/features/common/kibana_feature.ts @@ -164,6 +164,18 @@ export interface KibanaFeatureConfig { * Indicates whether the feature is available in Security Feature Privileges and the Spaces Visibility Toggles. */ scope?: readonly KibanaFeatureScope[]; + + /** + * If defined, the feature is considered deprecated and won't be available to users when configuring roles or Spaces. + */ + readonly deprecated?: Readonly<{ + /** + * The mandatory, localizable, user-facing notice that will be displayed to users whenever we need to explain why a + * feature is deprecated and what they should rely on instead. The notice can also include links to more detailed + * documentation. + */ + notice: string; + }>; } export class KibanaFeature { @@ -179,6 +191,10 @@ export class KibanaFeature { return this.config.id; } + public get deprecated() { + return this.config.deprecated; + } + public get hidden() { return this.config.hidden; } diff --git a/x-pack/plugins/features/common/sub_feature.ts b/x-pack/plugins/features/common/sub_feature.ts index a87dc2343e16d..3978d4fc88e8f 100644 --- a/x-pack/plugins/features/common/sub_feature.ts +++ b/x-pack/plugins/features/common/sub_feature.ts @@ -7,6 +7,7 @@ import { RecursiveReadonly } from '@kbn/utility-types'; import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { FeatureKibanaPrivilegesReference } from './feature_kibana_privileges_reference'; import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; /** @@ -70,7 +71,7 @@ export interface SubFeaturePrivilegeGroupConfig { * Configuration for a sub-feature privilege. */ export interface SubFeaturePrivilegeConfig - extends Omit { + extends Omit { /** * Identifier for this privilege. Must be unique across all other privileges within a feature. */ @@ -93,6 +94,13 @@ export interface SubFeaturePrivilegeConfig * that are valid for the overall feature. */ minimumLicense?: LicenseType; + + /** + * An optional list of other registered feature or sub-feature privileges that, when combined, grant equivalent access + * if the feature this sub-feature privilege belongs to becomes deprecated. This property can only be set if the + * feature is marked as deprecated. + */ + replacedBy?: readonly FeatureKibanaPrivilegesReference[]; } export class SubFeature { diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index 8f70f79435843..c91244e2f1d9d 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -1013,6 +1013,17 @@ Array [ }, "privilegeId": "all", }, + Object { + "privilege": Object { + "disabled": true, + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, ] `; @@ -1635,6 +1646,17 @@ Array [ }, "privilegeId": "all", }, + Object { + "privilege": Object { + "disabled": true, + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, ] `; diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index d9451fec632d8..08da08e0a19bd 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -6,7 +6,11 @@ */ import { FeatureRegistry } from './feature_registry'; -import { ElasticsearchFeatureConfig, KibanaFeatureConfig } from '../common'; +import { + ElasticsearchFeatureConfig, + FeatureKibanaPrivilegesReference, + KibanaFeatureConfig, +} from '../common'; import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; @@ -1914,6 +1918,25 @@ describe('FeatureRegistry', () => { }, ], }, + { + deprecated: { notice: 'It was a mistake.' }, + id: 'deprecated-feature', + name: 'Deprecated Feature', + app: [], + category: { id: 'deprecated', label: 'deprecated' }, + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'with-sub-feature', privileges: ['all'] }], + }, + read: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'with-sub-feature', privileges: ['all'] }], + }, + }, + }, ]; const registry = new FeatureRegistry(); @@ -1922,7 +1945,12 @@ describe('FeatureRegistry', () => { it('returns all features and sub-feature privileges by default', () => { const result = registry.getAllKibanaFeatures(); - expect(result).toHaveLength(3); + expect(result.map((f) => f.id)).toEqual([ + 'gold-feature', + 'unlicensed-feature', + 'with-sub-feature', + 'deprecated-feature', + ]); const [, , withSubFeature] = result; expect(withSubFeature.subFeatures).toHaveLength(1); expect(withSubFeature.subFeatures[0].privilegeGroups).toHaveLength(1); @@ -1931,18 +1959,32 @@ describe('FeatureRegistry', () => { it('returns features which are satisfied by the current license', () => { const license = licensingMock.createLicense({ license: { type: 'gold' } }); - const result = registry.getAllKibanaFeatures(license); - expect(result).toHaveLength(2); - const ids = result.map((f) => f.id); - expect(ids).toEqual(['gold-feature', 'unlicensed-feature']); + const result = registry.getAllKibanaFeatures({ license }); + expect(result.map((f) => f.id)).toEqual([ + 'gold-feature', + 'unlicensed-feature', + 'deprecated-feature', + ]); + }); + + it('can omit deprecated features if requested', () => { + const result = registry.getAllKibanaFeatures({ omitDeprecated: true }); + expect(result.map((f) => f.id)).toEqual([ + 'gold-feature', + 'unlicensed-feature', + 'with-sub-feature', + ]); }); it('filters out sub-feature privileges which do not match the current license', () => { const license = licensingMock.createLicense({ license: { type: 'platinum' } }); - const result = registry.getAllKibanaFeatures(license); - expect(result).toHaveLength(3); - const ids = result.map((f) => f.id); - expect(ids).toEqual(['gold-feature', 'unlicensed-feature', 'with-sub-feature']); + const result = registry.getAllKibanaFeatures({ license }); + expect(result.map((f) => f.id)).toEqual([ + 'gold-feature', + 'unlicensed-feature', + 'with-sub-feature', + 'deprecated-feature', + ]); const [, , withSubFeature] = result; expect(withSubFeature.subFeatures).toHaveLength(1); @@ -2214,6 +2256,669 @@ describe('FeatureRegistry', () => { ]); }); }); + + describe('#validateFeatures', () => { + function createRegistry(...features: KibanaFeatureConfig[]) { + const registry = new FeatureRegistry(); + + // Non-deprecated feature. + const featureBeta: KibanaFeatureConfig = { + id: 'feature-beta', + name: 'Feature Beta', + app: [], + category: { id: 'beta', label: 'beta' }, + privileges: { + all: { savedObject: { all: [], read: [] }, ui: [] }, + read: { savedObject: { all: [], read: [] }, ui: [] }, + }, + subFeatures: [ + { + name: 'sub-beta-1', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-beta-1-1', + name: 'Sub Beta 1-1', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + }, + { + id: 'sub-beta-1-2', + name: 'Sub Beta 1-2', + includeIn: 'read', + ui: [], + savedObject: { all: [], read: [] }, + }, + ], + }, + ], + }, + { + name: 'sub-beta-2', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-beta-2-1', + name: 'Sub Beta 2-1', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + }, + ], + }, + ], + }, + ], + }; + + // Deprecated feature + const featureGamma: KibanaFeatureConfig = { + deprecated: { notice: 'It was a mistake.' }, + id: 'feature-gamma', + name: 'Feature Gamma', + app: [], + category: { id: 'gamma', label: 'gamma' }, + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'feature-beta', privileges: ['all'] }], + }, + read: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }], + }, + }, + subFeatures: [ + { + name: 'sub-gamma-1', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-gamma-1-1', + name: 'Sub Gamma 1-1', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + replacedBy: [ + { feature: 'feature-beta', privileges: ['read', 'sub-beta-2-1'] }, + ], + }, + ], + }, + ], + }, + ], + }; + + // Non-deprecated feature with disabled privileges. + const featureDelta: KibanaFeatureConfig = { + id: 'feature-delta', + name: 'Feature Delta', + app: [], + category: { id: 'delta', label: 'delta' }, + privileges: { + all: { savedObject: { all: [], read: [] }, ui: [] }, + read: { savedObject: { all: [], read: [] }, ui: [], disabled: true }, + }, + }; + + for (const feature of [featureBeta, featureGamma, featureDelta, ...features]) { + registry.registerKibanaFeature(feature); + } + + registry.lockRegistration(); + return registry; + } + + function createDeprecatedFeature({ + all, + read, + subAlpha, + }: { + all?: FeatureKibanaPrivilegesReference[]; + read?: { + minimal: FeatureKibanaPrivilegesReference[]; + default: FeatureKibanaPrivilegesReference[]; + }; + subAlpha?: FeatureKibanaPrivilegesReference[]; + } = {}): KibanaFeatureConfig { + return { + deprecated: { notice: 'It was a mistake.' }, + id: 'feature-alpha', + name: 'Feature Alpha', + app: [], + category: { id: 'alpha', label: 'alpha' }, + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: all ?? [{ feature: 'feature-beta', privileges: ['all'] }], + }, + read: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: read ?? { + default: [{ feature: 'feature-beta', privileges: ['all'] }], + minimal: [{ feature: 'feature-beta', privileges: ['read', 'sub-beta-2-1'] }], + }, + }, + }, + subFeatures: [ + { + name: 'sub-alpha-1', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-alpha-1-1', + name: 'Sub Alpha 1-1', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + replacedBy: subAlpha ?? [ + { feature: 'feature-beta', privileges: ['sub-beta-1-1'] }, + ], + }, + ], + }, + ], + }, + ], + }; + } + + it('requires feature to be deprecated to define privilege replacements', () => { + const featureAlpha: KibanaFeatureConfig = { + id: 'feature-alpha', + name: 'Feature Alpha', + app: [], + category: { id: 'alpha', label: 'alpha' }, + privileges: { + all: { savedObject: { all: [], read: [] }, ui: [] }, + read: { savedObject: { all: [], read: [] }, ui: [] }, + }, + }; + + // Case 1: some top-level privileges define replacement. + let registry = createRegistry({ + ...featureAlpha, + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'feature-beta', privileges: ['all'] }], + }, + read: featureAlpha.privileges?.read!, + }, + }); + expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot( + `"Feature \\"feature-alpha\\" is not deprecated and must not define a \\"replacedBy\\" property for privilege \\"all\\"."` + ); + + // Case 2: some sub-feature privileges define replacement. + registry = createRegistry({ + ...featureAlpha, + subFeatures: [ + { + name: 'sub-alpha', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-alpha', + name: 'Sub Alpha', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + replacedBy: [{ feature: 'feature-beta', privileges: ['sub-alpha'] }], + }, + ], + }, + ], + }, + ], + }); + expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot( + `"Feature \\"feature-alpha\\" is not deprecated and must not define a \\"replacedBy\\" property for privilege \\"sub-alpha\\"."` + ); + + // Case 3: none of the privileges define replacement. + registry = createRegistry({ + ...featureAlpha, + subFeatures: [ + { + name: 'sub-alpha', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-alpha', + name: 'Sub Alpha', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + }, + ], + }, + ], + }, + ], + }); + expect(() => registry.validateFeatures()).not.toThrow(); + }); + + it('requires all top-level privileges of the deprecated feature to define replacement', () => { + const featureAlphaDeprecated: KibanaFeatureConfig = { + deprecated: { notice: 'It was a mistake.' }, + id: 'feature-alpha', + name: 'Feature Alpha', + app: [], + category: { id: 'alpha', label: 'alpha' }, + privileges: { + all: { savedObject: { all: [], read: [] }, ui: [] }, + read: { savedObject: { all: [], read: [] }, ui: [] }, + }, + }; + + // Case 1: all top-level privileges don't define replacement. + let registry = createRegistry(featureAlphaDeprecated); + expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot( + `"Feature \\"feature-alpha\\" is deprecated and must define a \\"replacedBy\\" property for privilege \\"all\\"."` + ); + + // Case 2: some top-level privileges don't define replacement. + registry = createRegistry({ + ...featureAlphaDeprecated, + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'feature-beta', privileges: ['all'] }], + }, + read: { savedObject: { all: [], read: [] }, ui: [] }, + }, + }); + expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot( + `"Feature \\"feature-alpha\\" is deprecated and must define a \\"replacedBy\\" property for privilege \\"read\\"."` + ); + + // Case 3: all top-level privileges define replacement. + registry = createRegistry({ + ...featureAlphaDeprecated, + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'feature-beta', privileges: ['all'] }], + }, + read: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }], + }, + }, + }); + expect(() => registry.validateFeatures()).not.toThrow(); + }); + + it('requires all sub-feature privileges of the deprecated feature to define replacement', () => { + const featureAlphaDeprecated: KibanaFeatureConfig = { + deprecated: { notice: 'It was a mistake.' }, + id: 'feature-alpha', + name: 'Feature Alpha', + app: [], + category: { id: 'alpha', label: 'alpha' }, + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'feature-beta', privileges: ['all'] }], + }, + read: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: { + default: [{ feature: 'feature-beta', privileges: ['all'] }], + minimal: [{ feature: 'feature-beta', privileges: ['read'] }], + }, + }, + }, + subFeatures: [ + { + name: 'sub-alpha-1', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-alpha-1-1', + name: 'Sub Alpha 1-1', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + }, + { + id: 'sub-alpha-1-2', + name: 'Sub Alpha 1-2', + includeIn: 'read', + ui: [], + savedObject: { all: [], read: [] }, + }, + ], + }, + ], + }, + { + name: 'sub-alpha-2', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-alpha-2-1', + name: 'Sub Alpha 2-1', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + }, + ], + }, + ], + }, + ], + }; + + // Case 1: all sub-feature privileges don't define replacement. + let registry = createRegistry(featureAlphaDeprecated); + expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot( + `"Feature \\"feature-alpha\\" is deprecated and must define a \\"replacedBy\\" property for privilege \\"sub-alpha-1-1\\"."` + ); + + // Case 2: some sub-feature privileges of some sub-features don't define replacement. + registry = createRegistry({ + ...featureAlphaDeprecated, + subFeatures: [ + { + name: 'sub-alpha-1', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-alpha-1-1', + name: 'Sub Alpha 1-1', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }], + }, + { + id: 'sub-alpha-1-2', + name: 'Sub Alpha 1-2', + includeIn: 'read', + ui: [], + savedObject: { all: [], read: [] }, + }, + ], + }, + ], + }, + featureAlphaDeprecated.subFeatures?.[1]!, + ], + }); + expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot( + `"Feature \\"feature-alpha\\" is deprecated and must define a \\"replacedBy\\" property for privilege \\"sub-alpha-1-2\\"."` + ); + + // Case 3: all sub-feature privileges of some sub-features don't define replacement. + registry = createRegistry({ + ...featureAlphaDeprecated, + subFeatures: [ + { + name: 'sub-alpha-1', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-alpha-1-1', + name: 'Sub Alpha 1-1', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }], + }, + { + id: 'sub-alpha-1-2', + name: 'Sub Alpha 1-2', + includeIn: 'read', + ui: [], + savedObject: { all: [], read: [] }, + replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }], + }, + ], + }, + ], + }, + featureAlphaDeprecated.subFeatures?.[1]!, + ], + }); + expect(() => registry.validateFeatures()).toThrowErrorMatchingInlineSnapshot( + `"Feature \\"feature-alpha\\" is deprecated and must define a \\"replacedBy\\" property for privilege \\"sub-alpha-2-1\\"."` + ); + + // Case 4: all top-level and sub-feature privileges define replacement. + registry = createRegistry({ + ...featureAlphaDeprecated, + subFeatures: [ + { + name: 'sub-alpha-1', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-alpha-1-1', + name: 'Sub Alpha 1-1', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }], + }, + { + id: 'sub-alpha-1-2', + name: 'Sub Alpha 1-2', + includeIn: 'read', + ui: [], + savedObject: { all: [], read: [] }, + replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }], + }, + ], + }, + ], + }, + { + name: 'sub-alpha-2', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-alpha-2-1', + name: 'Sub Alpha 2-1', + includeIn: 'all', + ui: [], + savedObject: { all: [], read: [] }, + replacedBy: [{ feature: 'feature-beta', privileges: ['read'] }], + }, + ], + }, + ], + }, + ], + }); + expect(() => registry.validateFeatures()).not.toThrow(); + }); + + it('requires referenced feature to exist', () => { + // Case 1: top-level privilege references to a non-existent feature. + expect(() => + createRegistry( + createDeprecatedFeature({ all: [{ feature: 'feature-unknown', privileges: ['all'] }] }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"all\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-unknown\\" since such feature is not registered."` + ); + + // Case 2: top-level privilege references to a non-existent feature (extended format). + expect(() => + createRegistry( + createDeprecatedFeature({ + read: { + default: [{ feature: 'feature-beta', privileges: ['all'] }], + minimal: [{ feature: 'feature-unknown', privileges: ['read', 'sub-beta-2-1'] }], + }, + }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"read\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-unknown\\" since such feature is not registered."` + ); + + // Case 3: sub-feature privilege references to a non-existent feature. + expect(() => + createRegistry( + createDeprecatedFeature({ + subAlpha: [{ feature: 'feature-unknown', privileges: ['sub-beta-1-1'] }], + }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"sub-alpha-1-1\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-unknown\\" since such feature is not registered."` + ); + + // Case 4: all top-level and sub-feature privileges define proper replacement. + expect(() => createRegistry(createDeprecatedFeature()).validateFeatures()).not.toThrow(); + }); + + it('requires referenced feature to not be deprecated', () => { + // Case 1: top-level privilege references to a deprecated feature. + expect(() => + createRegistry( + createDeprecatedFeature({ all: [{ feature: 'feature-gamma', privileges: ['all'] }] }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"all\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-gamma\\" since the referenced feature is deprecated."` + ); + + // Case 2: top-level privilege references to a deprecated feature (extended format). + expect(() => + createRegistry( + createDeprecatedFeature({ + read: { + default: [{ feature: 'feature-beta', privileges: ['all'] }], + minimal: [{ feature: 'feature-gamma', privileges: ['read'] }], + }, + }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"read\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-gamma\\" since the referenced feature is deprecated."` + ); + + // Case 3: sub-feature privilege references to a deprecated feature. + expect(() => + createRegistry( + createDeprecatedFeature({ + subAlpha: [{ feature: 'feature-gamma', privileges: ['sub-gamma-1-1'] }], + }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"sub-alpha-1-1\\" of deprecated feature \\"feature-alpha\\" with privileges of feature \\"feature-gamma\\" since the referenced feature is deprecated."` + ); + }); + + it('requires referenced privilege to exist', () => { + // Case 1: top-level privilege references to a non-existent privilege. + expect(() => + createRegistry( + createDeprecatedFeature({ all: [{ feature: 'feature-beta', privileges: ['all_v2'] }] }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"all\\" of deprecated feature \\"feature-alpha\\" with privilege \\"all_v2\\" of feature \\"feature-beta\\" since such privilege is not registered."` + ); + + // Case 2: top-level privilege references to a non-existent privilege (extended format). + expect(() => + createRegistry( + createDeprecatedFeature({ + read: { + default: [{ feature: 'feature-beta', privileges: ['all'] }], + minimal: [{ feature: 'feature-beta', privileges: ['read_v2'] }], + }, + }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"read\\" of deprecated feature \\"feature-alpha\\" with privilege \\"read_v2\\" of feature \\"feature-beta\\" since such privilege is not registered."` + ); + + // Case 3: sub-feature privilege references to a non-existent privilege. + expect(() => + createRegistry( + createDeprecatedFeature({ + subAlpha: [{ feature: 'feature-beta', privileges: ['sub-gamma-1-1_v2'] }], + }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"sub-alpha-1-1\\" of deprecated feature \\"feature-alpha\\" with privilege \\"sub-gamma-1-1_v2\\" of feature \\"feature-beta\\" since such privilege is not registered."` + ); + }); + + it('requires referenced privilege to not be disabled', () => { + // Case 1: top-level privilege references to a disabled privilege. + expect(() => + createRegistry( + createDeprecatedFeature({ all: [{ feature: 'feature-delta', privileges: ['read'] }] }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"all\\" of deprecated feature \\"feature-alpha\\" with disabled privilege \\"read\\" of feature \\"feature-delta\\"."` + ); + + // Case 2: top-level privilege references to a disabled privilege (extended format). + expect(() => + createRegistry( + createDeprecatedFeature({ + read: { + default: [{ feature: 'feature-beta', privileges: ['all'] }], + minimal: [{ feature: 'feature-delta', privileges: ['all', 'read'] }], + }, + }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"read\\" of deprecated feature \\"feature-alpha\\" with disabled privilege \\"read\\" of feature \\"feature-delta\\"."` + ); + + // Case 3: sub-feature privilege references to a disabled privilege. + expect(() => + createRegistry( + createDeprecatedFeature({ + subAlpha: [{ feature: 'feature-delta', privileges: ['read'] }], + }) + ).validateFeatures() + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot replace privilege \\"sub-alpha-1-1\\" of deprecated feature \\"feature-alpha\\" with disabled privilege \\"read\\" of feature \\"feature-delta\\"."` + ); + }); + }); }); describe('Elasticsearch Features', () => { diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts index 686a3f7d5c31d..adeb0f4cc9dab 100644 --- a/x-pack/plugins/features/server/feature_registry.ts +++ b/x-pack/plugins/features/server/feature_registry.ts @@ -20,6 +20,27 @@ import { import { validateKibanaFeature, validateElasticsearchFeature } from './feature_schema'; import type { ConfigOverridesType } from './config'; +/** + * Describes parameters used to retrieve all Kibana features. + */ +export interface GetKibanaFeaturesParams { + /** + * If provided, the license will be used to filter out features that require a license higher than the specified one. + * */ + license?: ILicense; + + /** + * If true, features that require a license higher than the one provided in the `license` will be included. + */ + ignoreLicense?: boolean; + + /** + * If true, deprecated features will be omitted. For backward compatibility reasons, deprecated features are included + * in the result by default. + */ + omitDeprecated?: boolean; +} + export class FeatureRegistry { private locked = false; private kibanaFeatures: Record = {}; @@ -169,19 +190,106 @@ export class FeatureRegistry { } } - public getAllKibanaFeatures(license?: ILicense, ignoreLicense = false): KibanaFeature[] { + /** + * Once all features are registered and the registry is locked, this method should validate the integrity of the registered feature set, including any potential cross-feature dependencies. + */ + public validateFeatures() { if (!this.locked) { - throw new Error('Cannot retrieve Kibana features while registration is still open'); + throw new Error( + 'Cannot validate features while the registry is not locked and still allows further feature registrations.' + ); + } + + for (const feature of Object.values(this.kibanaFeatures)) { + if (!feature.privileges) { + continue; + } + + // Iterate over all top-level and sub-feature privileges. + const isFeatureDeprecated = !!feature.deprecated; + for (const [privilegeId, privilege] of [ + ...Object.entries(feature.privileges), + ...collectSubFeaturesPrivileges(feature), + ]) { + if (isFeatureDeprecated && !privilege.replacedBy) { + throw new Error( + `Feature "${feature.id}" is deprecated and must define a "replacedBy" property for privilege "${privilegeId}".` + ); + } + + if (!isFeatureDeprecated && privilege.replacedBy) { + throw new Error( + `Feature "${feature.id}" is not deprecated and must not define a "replacedBy" property for privilege "${privilegeId}".` + ); + } + + const replacedByReferences = privilege.replacedBy + ? 'default' in privilege.replacedBy + ? [...privilege.replacedBy.default, ...privilege.replacedBy.minimal] + : privilege.replacedBy + : []; + for (const featureReference of replacedByReferences) { + const referencedFeature = this.kibanaFeatures[featureReference.feature]; + if (!referencedFeature) { + throw new Error( + `Cannot replace privilege "${privilegeId}" of deprecated feature "${feature.id}" with privileges of feature "${featureReference.feature}" since such feature is not registered.` + ); + } + + if (referencedFeature.deprecated) { + throw new Error( + `Cannot replace privilege "${privilegeId}" of deprecated feature "${feature.id}" with privileges of feature "${featureReference.feature}" since the referenced feature is deprecated.` + ); + } + + // Collect all known feature and sub-feature privileges for the referenced feature. + const knownPrivileges = new Map( + collectPrivileges(referencedFeature).concat( + collectSubFeaturesPrivileges(referencedFeature) + ) + ); + + for (const privilegeReference of featureReference.privileges) { + const referencedPrivilege = knownPrivileges.get(privilegeReference); + if (!referencedPrivilege) { + throw new Error( + `Cannot replace privilege "${privilegeId}" of deprecated feature "${feature.id}" with privilege "${privilegeReference}" of feature "${featureReference.feature}" since such privilege is not registered.` + ); + } + + if (referencedPrivilege.disabled) { + throw new Error( + `Cannot replace privilege "${privilegeId}" of deprecated feature "${feature.id}" with disabled privilege "${privilegeReference}" of feature "${featureReference.feature}".` + ); + } + } + } + } } + } - let features = Object.values(this.kibanaFeatures); + public getAllKibanaFeatures({ + license, + ignoreLicense = false, + omitDeprecated = false, + }: GetKibanaFeaturesParams = {}): KibanaFeature[] { + if (!this.locked) { + throw new Error('Cannot retrieve Kibana features while registration is still open'); + } const performLicenseCheck = license && !ignoreLicense; + const features = []; + for (const feature of Object.values(this.kibanaFeatures)) { + if (omitDeprecated && feature.deprecated) { + continue; + } - if (performLicenseCheck) { - features = features.filter((feature) => { - const filter = !feature.minimumLicense || license!.hasAtLeast(feature.minimumLicense); - if (!filter) return false; + if (performLicenseCheck) { + const isCompatibleLicense = + !feature.minimumLicense || license!.hasAtLeast(feature.minimumLicense); + if (!isCompatibleLicense) { + continue; + } feature.subFeatures?.forEach((subFeature) => { subFeature.privilegeGroups.forEach((group) => { @@ -191,11 +299,12 @@ export class FeatureRegistry { ); }); }); + } - return true; - }); + features.push(new KibanaFeature(feature)); } - return features.map((featureConfig) => new KibanaFeature(featureConfig)); + + return features; } public getAllElasticsearchFeatures(): ElasticsearchFeature[] { @@ -252,6 +361,16 @@ function applyAutomaticReadPrivilegeGrants( }); } +function collectPrivileges(feature: KibanaFeatureConfig) { + return Object.entries(feature.privileges ?? {}).flatMap( + ([id, privilege]) => + [ + [id, privilege], + [`minimal_${id}`, privilege], + ] as Array<[string, FeatureKibanaPrivileges]> + ); +} + function collectSubFeaturesPrivileges(feature: KibanaFeatureConfig) { return ( feature.subFeatures?.flatMap((subFeature) => diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index bd60eaa84f51c..581fdc1037e2a 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -116,6 +116,21 @@ const kibanaPrivilegeSchema = schema.object({ read: schema.arrayOf(schema.string()), }), ui: listOfCapabilitiesSchema, + replacedBy: schema.maybe( + schema.oneOf([ + schema.arrayOf( + schema.object({ feature: schema.string(), privileges: schema.arrayOf(schema.string()) }) + ), + schema.object({ + minimal: schema.arrayOf( + schema.object({ feature: schema.string(), privileges: schema.arrayOf(schema.string()) }) + ), + default: schema.arrayOf( + schema.object({ feature: schema.string(), privileges: schema.arrayOf(schema.string()) }) + ), + }), + ]) + ), }); const kibanaIndependentSubFeaturePrivilegeSchema = schema.object({ @@ -155,6 +170,11 @@ const kibanaIndependentSubFeaturePrivilegeSchema = schema.object({ read: schema.arrayOf(schema.string()), }), ui: listOfCapabilitiesSchema, + replacedBy: schema.maybe( + schema.arrayOf( + schema.object({ feature: schema.string(), privileges: schema.arrayOf(schema.string()) }) + ) + ), }); const kibanaMutuallyExclusiveSubFeaturePrivilegeSchema = @@ -256,6 +276,7 @@ const kibanaFeatureSchema = schema.object({ ), }) ), + deprecated: schema.maybe(schema.object({ notice: schema.string() })), }); const elasticsearchPrivilegeSchema = schema.object({ diff --git a/x-pack/plugins/features/server/index.ts b/x-pack/plugins/features/server/index.ts index 4be87f7033fca..734a1aa256f73 100644 --- a/x-pack/plugins/features/server/index.ts +++ b/x-pack/plugins/features/server/index.ts @@ -21,6 +21,7 @@ export type { ElasticsearchFeatureConfig, FeatureElasticsearchPrivileges, } from '../common'; +export type { SubFeaturePrivilegeIterator } from './feature_privilege_iterator'; export { KibanaFeature, ElasticsearchFeature } from '../common'; export type { FeaturesPluginSetup, FeaturesPluginStart } from './plugin'; diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index abc66ea61b199..19001fe19547e 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -555,10 +555,12 @@ export const buildOSSFeatures = ({ read: [], }, ui: ['saveQuery'], - }, // No read-only mode supported + }, + // No read-only mode supported + read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] }, }, }, - ] as KibanaFeatureConfig[]; + ]; }; const reportingPrivilegeGroupName = i18n.translate( diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts index f564c23fd2a40..15888358bb773 100644 --- a/x-pack/plugins/features/server/plugin.ts +++ b/x-pack/plugins/features/server/plugin.ts @@ -135,6 +135,7 @@ export class FeaturesPlugin } this.featureRegistry.lockRegistration(); + this.featureRegistry.validateFeatures(); this.capabilities = uiCapabilitiesForFeatures( this.featureRegistry.getAllKibanaFeatures(), diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index 5f70683be85ea..8725faa6a8c58 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -44,6 +44,7 @@ function getExpectedSubFeatures(licenseType: LicenseType = 'platinum'): SubFeatu name: 'basic sub 1', includeIn: 'all', ...createPrivilege(), + replacedBy: undefined, }, ], }, @@ -63,6 +64,7 @@ function getExpectedSubFeatures(licenseType: LicenseType = 'platinum'): SubFeatu includeIn: 'all', minimumLicense: 'platinum', ...createPrivilege(), + replacedBy: undefined, }, ] : [], @@ -75,6 +77,7 @@ function getExpectedSubFeatures(licenseType: LicenseType = 'platinum'): SubFeatu name: 'platinum sub 1', includeIn: 'all', ...createPrivilege(), + replacedBy: undefined, }, ], }, @@ -126,6 +129,26 @@ describe('GET /api/features', () => { privileges: null, }); + featureRegistry.registerKibanaFeature({ + deprecated: { notice: 'It was a mistake.' }, + id: 'deprecated-feature', + name: 'Deprecated Feature', + app: [], + category: { id: 'deprecated', label: 'deprecated' }, + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'feature_1', privileges: ['all'] }], + }, + read: { + savedObject: { all: [], read: [] }, + ui: [], + replacedBy: [{ feature: 'feature_1', privileges: ['all'] }], + }, + }, + }); + featureRegistry.lockRegistration(); const routerMock = httpServiceMock.createRouter(); @@ -137,7 +160,7 @@ describe('GET /api/features', () => { routeHandler = routerMock.get.mock.calls[0][1]; }); - it('returns a list of available features, sorted by their configured order', async () => { + it('returns a list of available features omitting deprecated ones, sorted by their configured order', async () => { const mockResponse = httpServerMock.createResponseFactory(); await routeHandler(createContextMock(), { query: {} } as any, mockResponse); diff --git a/x-pack/plugins/features/server/routes/index.ts b/x-pack/plugins/features/server/routes/index.ts index 97273776df652..b0da6cf4a0659 100644 --- a/x-pack/plugins/features/server/routes/index.ts +++ b/x-pack/plugins/features/server/routes/index.ts @@ -33,10 +33,13 @@ export function defineRoutes({ router, featureRegistry }: RouteDefinitionParams) async (context, request, response) => { const { license: currentLicense } = await context.licensing; - const allFeatures = featureRegistry.getAllKibanaFeatures( - currentLicense, - request.query.ignoreValidLicenses - ); + const allFeatures = featureRegistry.getAllKibanaFeatures({ + license: currentLicense, + ignoreLicense: request.query.ignoreValidLicenses, + // This API is used to power user-facing UIs, which, unlike our server-side internal backward compatibility + // mechanisms, shouldn't display deprecated features. + omitDeprecated: true, + }); return response.ok({ body: allFeatures diff --git a/x-pack/plugins/security/common/index.ts b/x-pack/plugins/security/common/index.ts index c4d76f7c9fd66..f61b923656efa 100644 --- a/x-pack/plugins/security/common/index.ts +++ b/x-pack/plugins/security/common/index.ts @@ -24,8 +24,6 @@ export type { export { getUserDisplayName, isRoleReserved, isRoleWithWildcardBasePrivilege } from './model'; -export type { RawKibanaPrivileges } from '@kbn/security-authorization-core'; - // Re-export types from the plugin directly to enhance the developer experience for consumers of the Security plugin. export type { AuthenticatedUser, @@ -39,6 +37,8 @@ export type { RoleRemoteClusterPrivilege, FeaturesPrivileges, LoginLayout, + RawKibanaPrivileges, + RawKibanaFeaturePrivileges, SecurityLicenseFeatures, SecurityLicense, UserProfile, diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 1331d60d624b6..f0a91e95c66b6 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -21,10 +21,6 @@ export { } from './authenticated_user'; export { shouldProviderUseLoginForm } from './authentication_provider'; export type { BuiltinESPrivileges } from './builtin_es_privileges'; -export type { - RawKibanaPrivileges, - RawKibanaFeaturePrivileges, -} from '@kbn/security-authorization-core'; export { copyRole, isRoleDeprecated, diff --git a/x-pack/plugins/security/public/management/roles/privileges_api_client.ts b/x-pack/plugins/security/public/management/roles/privileges_api_client.ts index 5032871c41fa6..5e51636b29494 100644 --- a/x-pack/plugins/security/public/management/roles/privileges_api_client.ts +++ b/x-pack/plugins/security/public/management/roles/privileges_api_client.ts @@ -6,7 +6,7 @@ */ import type { HttpStart } from '@kbn/core/public'; -import type { RawKibanaPrivileges } from '@kbn/security-authorization-core'; +import type { RawKibanaPrivileges } from '@kbn/security-plugin-types-common'; import { PrivilegesAPIClientPublicContract } from '@kbn/security-plugin-types-public'; import type { BuiltinESPrivileges } from '../../../common/model'; diff --git a/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts b/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts index 688aa78699769..d9be7f2c2d47f 100644 --- a/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts +++ b/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts @@ -337,4 +337,36 @@ describe('RolesAPIClient', () => { }); }); }); + + describe('#getRole', () => { + it('should request role with replaced deprecated privileges', async () => { + const httpMock = httpServiceMock.createStartContract(); + const roleName = 'my role'; + const rolesAPIClient = new RolesAPIClient(httpMock); + + await rolesAPIClient.getRole(roleName); + + expect(httpMock.get).toHaveBeenCalledTimes(1); + expect(httpMock.get).toHaveBeenCalledWith( + `/api/security/role/${encodeURIComponent(roleName)}`, + { + query: { replaceDeprecatedPrivileges: true }, + } + ); + }); + }); + + describe('#getRoles', () => { + it('should request roles with replaced deprecated privileges', async () => { + const httpMock = httpServiceMock.createStartContract(); + const rolesAPIClient = new RolesAPIClient(httpMock); + + await rolesAPIClient.getRoles(); + + expect(httpMock.get).toHaveBeenCalledTimes(1); + expect(httpMock.get).toHaveBeenCalledWith('/api/security/role', { + query: { replaceDeprecatedPrivileges: true }, + }); + }); + }); }); diff --git a/x-pack/plugins/security/public/management/roles/roles_api_client.ts b/x-pack/plugins/security/public/management/roles/roles_api_client.ts index d6dcab658d21c..5fce89dc3610f 100644 --- a/x-pack/plugins/security/public/management/roles/roles_api_client.ts +++ b/x-pack/plugins/security/public/management/roles/roles_api_client.ts @@ -15,11 +15,15 @@ export class RolesAPIClient { constructor(private readonly http: HttpStart) {} public getRoles = async () => { - return await this.http.get('/api/security/role'); + return await this.http.get('/api/security/role', { + query: { replaceDeprecatedPrivileges: true }, + }); }; public getRole = async (roleName: string) => { - return await this.http.get(`/api/security/role/${encodeURIComponent(roleName)}`); + return await this.http.get(`/api/security/role/${encodeURIComponent(roleName)}`, { + query: { replaceDeprecatedPrivileges: true }, + }); }; public deleteRole = async (roleName: string) => { diff --git a/x-pack/plugins/security/server/authorization/privileges_serializer.ts b/x-pack/plugins/security/server/authorization/privileges_serializer.ts index 8679dbefab4df..df6b9e7ea7da1 100644 --- a/x-pack/plugins/security/server/authorization/privileges_serializer.ts +++ b/x-pack/plugins/security/server/authorization/privileges_serializer.ts @@ -6,7 +6,7 @@ */ import { PrivilegeSerializer } from './privilege_serializer'; -import type { RawKibanaPrivileges } from '../../common/model'; +import type { RawKibanaPrivileges } from '../../common'; interface SerializedPrivilege { application: string; diff --git a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts index 89b556f416843..f4b5873906f6d 100644 --- a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts +++ b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts @@ -11,7 +11,7 @@ import type { Logger } from '@kbn/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; -import type { RawKibanaPrivileges } from '../../common/model'; +import type { RawKibanaPrivileges } from '../../common'; const application = 'default-application'; const registerPrivilegesWithClusterTest = ( diff --git a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts index bb8b43c45f272..6e3f6751d11dc 100644 --- a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts @@ -7,10 +7,11 @@ import { omit, pick } from 'lodash'; import { KibanaFeature } from '@kbn/features-plugin/server'; +import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { transformElasticsearchRoleToRole } from './elasticsearch_role'; -import type { ElasticsearchRole } from './elasticsearch_role'; +import type { ElasticsearchRole, TransformRoleOptions } from './elasticsearch_role'; const roles = [ { @@ -202,13 +203,14 @@ function testRoles( expected: any ) { const transformedRoles = elasticsearchRoles.map((role) => { - const transformedRole = transformElasticsearchRoleToRole( + const transformedRole = transformElasticsearchRoleToRole({ features, - omit(role, 'name'), - role.name, - 'kibana-.kibana', - loggerMock.create() - ); + elasticsearchRole: omit(role, 'name'), + name: role.name, + application: 'kibana-.kibana', + logger: loggerMock.create(), + subFeaturePrivilegeIterator: featuresPluginMock.createSetup().subFeaturePrivilegeIterator, + }); return pick(transformedRole, ['name', '_transform_error']); }); @@ -320,13 +322,14 @@ describe('#transformElasticsearchRoleToRole', () => { }, }; - const transformedRole = transformElasticsearchRoleToRole( - featuresWithRequireAllSpaces, - omit(role, 'name'), - role.name, - 'kibana-.kibana', - loggerMock.create() - ); + const transformedRole = transformElasticsearchRoleToRole({ + features: featuresWithRequireAllSpaces, + elasticsearchRole: omit(role, 'name'), + name: role.name, + application: 'kibana-.kibana', + logger: loggerMock.create(), + subFeaturePrivilegeIterator: featuresPluginMock.createSetup().subFeaturePrivilegeIterator, + }); const [privilege] = transformedRole.kibana; const [basePrivilege] = privilege.base; @@ -335,4 +338,367 @@ describe('#transformElasticsearchRoleToRole', () => { expect(basePrivilege).toBe('*'); expect(spacePrivilege).toBe('*'); }); + + it('properly handles privileges from deprecated features', () => { + const applicationName = 'kibana-.kibana'; + const features: KibanaFeature[] = [ + new KibanaFeature({ + deprecated: { notice: 'It is deprecated, sorry.' }, + id: 'alpha', + name: 'Feature Alpha', + app: [], + category: { id: 'alpha', label: 'alpha' }, + privileges: { + all: { + savedObject: { + all: ['all-alpha-all-so'], + read: ['all-alpha-read-so'], + }, + ui: ['all-alpha-ui'], + app: ['all-alpha-app'], + api: ['all-alpha-api'], + replacedBy: [{ feature: 'beta', privileges: ['all'] }], + }, + read: { + savedObject: { + all: ['read-alpha-all-so'], + read: ['read-alpha-read-so'], + }, + ui: ['read-alpha-ui'], + app: ['read-alpha-app'], + api: ['read-alpha-api'], + replacedBy: { + default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }], + minimal: [{ feature: 'beta', privileges: ['minimal_read'] }], + }, + }, + }, + subFeatures: [ + { + name: 'sub-feature-alpha', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_alpha', + name: 'Sub Feature Alpha', + includeIn: 'all', + savedObject: { + all: ['sub-alpha-all-so'], + read: ['sub-alpha-read-so'], + }, + ui: ['sub-alpha-ui'], + app: ['sub-alpha-app'], + api: ['sub-alpha-api'], + replacedBy: [ + { feature: 'beta', privileges: ['minimal_read'] }, + { feature: 'beta', privileges: ['sub_beta'] }, + ], + }, + ], + }, + ], + }, + ], + }), + new KibanaFeature({ + id: 'beta', + name: 'Feature Beta', + app: [], + category: { id: 'beta', label: 'beta' }, + privileges: { + all: { + savedObject: { + all: ['all-beta-all-so'], + read: ['all-beta-read-so'], + }, + ui: ['all-beta-ui'], + app: ['all-beta-app'], + api: ['all-beta-api'], + }, + read: { + savedObject: { + all: ['read-beta-all-so'], + read: ['read-beta-read-so'], + }, + ui: ['read-beta-ui'], + app: ['read-beta-app'], + api: ['read-beta-api'], + }, + }, + subFeatures: [ + { + name: 'sub-feature-beta', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_beta', + name: 'Sub Feature Beta', + includeIn: 'all', + savedObject: { + all: ['sub-beta-all-so'], + read: ['sub-beta-read-so'], + }, + ui: ['sub-beta-ui'], + app: ['sub-beta-app'], + api: ['sub-beta-api'], + }, + ], + }, + ], + }, + ], + }), + ]; + const getTransformRoleParams = ( + params: Pick + ) => ({ + features, + name: 'old-role', + elasticsearchRole: params.elasticsearchRole, + application: applicationName, + logger: loggerMock.create(), + subFeaturePrivilegeIterator: featuresPluginMock.createSetup().subFeaturePrivilegeIterator, + replaceDeprecatedKibanaPrivileges: params.replaceDeprecatedKibanaPrivileges, + }); + + const getRole = (appPrivileges: string[]) => ({ + name: 'old-role', + cluster: [], + remote_cluster: [], + indices: [], + applications: [{ application: applicationName, privileges: appPrivileges, resources: ['*'] }], + run_as: [], + metadata: {}, + transient_metadata: { enabled: true }, + }); + + // The `replaceDeprecatedKibanaPrivileges` is false, the deprecated privileges are returned as is. + { + const kibanaRole = transformElasticsearchRoleToRole( + getTransformRoleParams({ + elasticsearchRole: getRole([ + 'feature_alpha.all', + 'feature_alpha.read', + 'feature_alpha.minimal_all', + 'feature_alpha.minimal_read', + 'feature_alpha.sub_alpha', + ]), + replaceDeprecatedKibanaPrivileges: false, + }) + ); + expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(` + Array [ + Object { + "alpha": Array [ + "all", + "read", + "minimal_all", + "minimal_read", + "sub_alpha", + ], + }, + ] + `); + } + + // The non-deprecated, but referenced privileges aren't affected. + { + const kibanaRole = transformElasticsearchRoleToRole( + getTransformRoleParams({ + elasticsearchRole: getRole([ + 'feature_beta.all', + 'feature_beta.read', + 'feature_beta.minimal_all', + 'feature_beta.minimal_read', + 'feature_beta.sub_beta', + ]), + replaceDeprecatedKibanaPrivileges: false, + }) + ); + expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(` + Array [ + Object { + "beta": Array [ + "all", + "read", + "minimal_all", + "minimal_read", + "sub_beta", + ], + }, + ] + `); + } + + // The `replaceDeprecatedKibanaPrivileges` is true, top-level privilege is replaced (simple format). + { + const kibanaRole = transformElasticsearchRoleToRole( + getTransformRoleParams({ + elasticsearchRole: getRole(['feature_alpha.all']), + replaceDeprecatedKibanaPrivileges: true, + }) + ); + expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(` + Array [ + Object { + "beta": Array [ + "all", + ], + }, + ] + `); + } + + // The `replaceDeprecatedKibanaPrivileges` is true, top-level privilege is replaced (extended format). + { + const kibanaRole = transformElasticsearchRoleToRole( + getTransformRoleParams({ + elasticsearchRole: getRole(['feature_alpha.read']), + replaceDeprecatedKibanaPrivileges: true, + }) + ); + expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(` + Array [ + Object { + "beta": Array [ + "read", + "sub_beta", + ], + }, + ] + `); + } + + // The `replaceDeprecatedKibanaPrivileges` is true, top-level minimal privilege is replaced (simple format). + { + const kibanaRole = transformElasticsearchRoleToRole( + getTransformRoleParams({ + elasticsearchRole: getRole(['feature_alpha.minimal_all']), + replaceDeprecatedKibanaPrivileges: true, + }) + ); + expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(` + Array [ + Object { + "beta": Array [ + "all", + ], + }, + ] + `); + } + + // The `replaceDeprecatedKibanaPrivileges` is true, top-level minimal privilege is replaced (extended format). + { + const kibanaRole = transformElasticsearchRoleToRole( + getTransformRoleParams({ + elasticsearchRole: getRole(['feature_alpha.minimal_read']), + replaceDeprecatedKibanaPrivileges: true, + }) + ); + expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(` + Array [ + Object { + "beta": Array [ + "minimal_read", + ], + }, + ] + `); + } + + // The `replaceDeprecatedKibanaPrivileges` is true, sub-feature privilege is replaced. + { + const kibanaRole = transformElasticsearchRoleToRole( + getTransformRoleParams({ + elasticsearchRole: getRole(['feature_alpha.sub_alpha']), + replaceDeprecatedKibanaPrivileges: true, + }) + ); + expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(` + Array [ + Object { + "beta": Array [ + "minimal_read", + "sub_beta", + ], + }, + ] + `); + } + + // The `replaceDeprecatedKibanaPrivileges` is true, replaces all privileges that needed. + { + const kibanaRole = transformElasticsearchRoleToRole( + getTransformRoleParams({ + elasticsearchRole: getRole([ + 'feature_alpha.all', + 'feature_alpha.read', + 'feature_alpha.minimal_all', + 'feature_alpha.minimal_read', + 'feature_alpha.sub_alpha', + 'feature_gamma.all', + ]), + replaceDeprecatedKibanaPrivileges: true, + }) + ); + expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(` + Array [ + Object { + "beta": Array [ + "all", + "read", + "sub_beta", + "minimal_read", + ], + "gamma": Array [ + "all", + ], + }, + ] + `); + } + + // The `replaceDeprecatedKibanaPrivileges` is true, replaces and deduplicate privileges. + { + const kibanaRole = transformElasticsearchRoleToRole( + getTransformRoleParams({ + elasticsearchRole: getRole([ + 'feature_alpha.all', + 'feature_alpha.read', + 'feature_alpha.minimal_all', + 'feature_alpha.minimal_read', + 'feature_alpha.sub_alpha', + 'feature_gamma.all', + 'feature_beta.all', + 'feature_beta.read', + 'feature_beta.minimal_all', + 'feature_beta.minimal_read', + 'feature_beta.sub_beta', + ]), + replaceDeprecatedKibanaPrivileges: true, + }) + ); + expect(kibanaRole.kibana.map(({ feature }) => feature)).toMatchInlineSnapshot(` + Array [ + Object { + "beta": Array [ + "all", + "read", + "sub_beta", + "minimal_read", + "minimal_all", + ], + "gamma": Array [ + "all", + ], + }, + ] + `); + } + }); }); diff --git a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts index e06c9938b078f..2fe48f8a38663 100644 --- a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts @@ -6,10 +6,13 @@ */ import type { Logger } from '@kbn/core/server'; -import type { KibanaFeature } from '@kbn/features-plugin/common'; +import type { FeatureKibanaPrivileges, KibanaFeature } from '@kbn/features-plugin/common'; +import type { SubFeaturePrivilegeIterator } from '@kbn/features-plugin/server'; +import { getReplacedByForPrivilege } from '@kbn/security-authorization-core'; +import { getMinimalPrivilegeId } from '@kbn/security-authorization-core-common'; import { GLOBAL_RESOURCE } from '@kbn/security-plugin-types-server'; -import type { Role, RoleKibanaPrivilege } from '../../../common'; +import type { FeaturesPrivileges, Role } from '../../../common'; import { PRIVILEGES_ALL_WILDCARD, RESERVED_PRIVILEGES_APPLICATION_WILDCARD, @@ -35,21 +38,35 @@ export type ElasticsearchRole = Pick< }; const isReservedPrivilege = (app: string) => app === RESERVED_PRIVILEGES_APPLICATION_WILDCARD; -const isWildcardPrivilage = (app: string) => app === PRIVILEGES_ALL_WILDCARD; - -export function transformElasticsearchRoleToRole( - features: KibanaFeature[], - elasticsearchRole: Omit, - name: string, - application: string, - logger: Logger -): Role { - const kibanaTransformResult = transformRoleApplicationsToKibanaPrivileges( +const isWildcardPrivilege = (app: string) => app === PRIVILEGES_ALL_WILDCARD; + +export interface TransformRoleOptions { + features: KibanaFeature[]; + elasticsearchRole: Omit; + name: string; + application: string; + logger: Logger; + subFeaturePrivilegeIterator: SubFeaturePrivilegeIterator; + replaceDeprecatedKibanaPrivileges?: boolean; +} + +export function transformElasticsearchRoleToRole({ + features, + elasticsearchRole, + name, + application, + logger, + subFeaturePrivilegeIterator, + replaceDeprecatedKibanaPrivileges, +}: TransformRoleOptions): Role { + const kibanaTransformResult = transformRoleApplicationsToKibanaPrivileges({ features, - elasticsearchRole.applications, + roleApplications: elasticsearchRole.applications, application, - logger - ); + logger, + subFeaturePrivilegeIterator, + replaceDeprecatedKibanaPrivileges, + }); return { name, ...(elasticsearchRole.description && { description: elasticsearchRole.description }), @@ -71,17 +88,28 @@ export function transformElasticsearchRoleToRole( }; } -function transformRoleApplicationsToKibanaPrivileges( - features: KibanaFeature[], - roleApplications: ElasticsearchRole['applications'], - application: string, - logger: Logger -) { +interface TransformRoleApplicationsOptions { + features: KibanaFeature[]; + roleApplications: ElasticsearchRole['applications']; + application: string; + logger: Logger; + subFeaturePrivilegeIterator: SubFeaturePrivilegeIterator; + replaceDeprecatedKibanaPrivileges?: boolean; +} + +function transformRoleApplicationsToKibanaPrivileges({ + features, + roleApplications, + application, + logger, + subFeaturePrivilegeIterator, + replaceDeprecatedKibanaPrivileges, +}: TransformRoleApplicationsOptions) { const roleKibanaApplications = roleApplications.filter( (roleApplication) => roleApplication.application === application || isReservedPrivilege(roleApplication.application) || - isWildcardPrivilage(roleApplication.application) + isWildcardPrivilege(roleApplication.application) ); // if any application entry contains an empty resource, we throw an error @@ -94,11 +122,11 @@ function transformRoleApplicationsToKibanaPrivileges( if ( roleKibanaApplications.some( (entry) => - (isReservedPrivilege(entry.application) || isWildcardPrivilage(entry.application)) && + (isReservedPrivilege(entry.application) || isWildcardPrivilege(entry.application)) && !entry.privileges.every( (privilege) => PrivilegeSerializer.isSerializedReservedPrivilege(privilege) || - isWildcardPrivilage(privilege) + isWildcardPrivilege(privilege) ) ) ) { @@ -112,7 +140,7 @@ function transformRoleApplicationsToKibanaPrivileges( roleKibanaApplications.some( (entry) => !isReservedPrivilege(entry.application) && - !isWildcardPrivilage(entry.application) && + !isWildcardPrivilege(entry.application) && entry.privileges.some((privilege) => PrivilegeSerializer.isSerializedReservedPrivilege(privilege) ) @@ -188,7 +216,7 @@ function transformRoleApplicationsToKibanaPrivileges( const allResources = roleKibanaApplications .filter( - (entry) => !isReservedPrivilege(entry.application) && !isWildcardPrivilage(entry.application) + (entry) => !isReservedPrivilege(entry.application) && !isWildcardPrivilege(entry.application) ) .flatMap((entry) => entry.resources); @@ -252,6 +280,13 @@ function transformRoleApplicationsToKibanaPrivileges( // try/catch block ensures graceful return on deserialize exceptions try { const transformResult = roleKibanaApplications.map(({ resources, privileges }) => { + const featurePrivileges = deserializeKibanaFeaturePrivileges({ + features, + subFeaturePrivilegeIterator, + serializedPrivileges: privileges, + replaceDeprecatedKibanaPrivileges, + }); + // if we're dealing with a global entry, which we've ensured above is only possible if it's the only item in the array if (resources.length === 1 && resources[0] === GLOBAL_RESOURCE) { const reservedPrivileges = privileges.filter((privilege) => @@ -260,10 +295,6 @@ function transformRoleApplicationsToKibanaPrivileges( const basePrivileges = privileges.filter((privilege) => PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) ); - const featurePrivileges = privileges.filter((privilege) => - PrivilegeSerializer.isSerializedFeaturePrivilege(privilege) - ); - return { ...(reservedPrivileges.length ? { @@ -275,14 +306,7 @@ function transformRoleApplicationsToKibanaPrivileges( base: basePrivileges.map((privilege) => PrivilegeSerializer.serializeGlobalBasePrivilege(privilege) ), - feature: featurePrivileges.reduce((acc, privilege) => { - const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege); - acc[featurePrivilege.featureId] = getUniqueList([ - ...(acc[featurePrivilege.featureId] || []), - featurePrivilege.privilege, - ]); - return acc; - }, {} as RoleKibanaPrivilege['feature']), + feature: featurePrivileges, spaces: ['*'], }; } @@ -290,21 +314,11 @@ function transformRoleApplicationsToKibanaPrivileges( const basePrivileges = privileges.filter((privilege) => PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) ); - const featurePrivileges = privileges.filter((privilege) => - PrivilegeSerializer.isSerializedFeaturePrivilege(privilege) - ); return { base: basePrivileges.map((privilege) => PrivilegeSerializer.deserializeSpaceBasePrivilege(privilege) ), - feature: featurePrivileges.reduce((acc, privilege) => { - const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege); - acc[featurePrivilege.featureId] = getUniqueList([ - ...(acc[featurePrivilege.featureId] || []), - featurePrivilege.privilege, - ]); - return acc; - }, {} as RoleKibanaPrivilege['feature']), + feature: featurePrivileges, spaces: resources.map((resource) => ResourceSerializer.deserializeSpaceResource(resource)), }; }); @@ -331,7 +345,7 @@ const extractUnrecognizedApplicationNames = ( (roleApplication) => roleApplication.application !== application && !isReservedPrivilege(roleApplication.application) && - !isWildcardPrivilage(roleApplication.application) + !isWildcardPrivilege(roleApplication.application) ) .map((roleApplication) => roleApplication.application) ); @@ -352,3 +366,83 @@ export const compareRolesByName = (roleA: Role, roleB: Role) => { return 0; }; + +interface DeserializeFeaturePrivilegesOptions { + features: KibanaFeature[]; + serializedPrivileges: string[]; + subFeaturePrivilegeIterator: SubFeaturePrivilegeIterator; + replaceDeprecatedKibanaPrivileges?: boolean; +} + +function deserializeKibanaFeaturePrivileges({ + features, + subFeaturePrivilegeIterator, + serializedPrivileges, + replaceDeprecatedKibanaPrivileges, +}: DeserializeFeaturePrivilegesOptions) { + // Filter out deprecated features upfront to avoid going through ALL features within a loop. + const deprecatedFeatures = replaceDeprecatedKibanaPrivileges + ? features.filter((feature) => feature.deprecated) + : undefined; + const result = {} as FeaturesPrivileges; + for (const serializedPrivilege of serializedPrivileges) { + if (!PrivilegeSerializer.isSerializedFeaturePrivilege(serializedPrivilege)) { + continue; + } + + const { featureId, privilege: privilegeId } = + PrivilegeSerializer.deserializeFeaturePrivilege(serializedPrivilege); + + // If feature privileges are deprecated, replace them with non-deprecated feature privileges according to the + // deprecation "mapping". + const deprecatedFeature = deprecatedFeatures?.find((feature) => feature.id === featureId); + if (deprecatedFeature) { + const privilege = getPrivilegeById( + deprecatedFeature, + privilegeId, + subFeaturePrivilegeIterator + ); + + const replacedBy = privilege ? getReplacedByForPrivilege(privilegeId, privilege) : undefined; + if (!replacedBy) { + throw new Error( + `A deprecated feature "${featureId}" is missing a replacement for the "${privilegeId}" privilege.` + ); + } + + for (const reference of replacedBy) { + result[reference.feature] = getUniqueList([ + ...(result[reference.feature] || []), + ...reference.privileges, + ]); + } + } else { + result[featureId] = getUniqueList([...(result[featureId] || []), privilegeId]); + } + } + + return result; +} + +function getPrivilegeById( + feature: KibanaFeature, + privilegeId: string, + subFeaturePrivilegeIterator: SubFeaturePrivilegeIterator +): FeatureKibanaPrivileges | undefined { + for (const topLevelPrivilege of ['all' as const, 'read' as const]) { + if ( + privilegeId === topLevelPrivilege || + privilegeId === getMinimalPrivilegeId(topLevelPrivilege) + ) { + return feature.privileges?.[topLevelPrivilege]; + } + } + + // Don't perform license check as it should be done during feature registration (once we support + // license checks for deprecated privileges). + for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature, () => true)) { + if (subFeaturePrivilege.id === privilegeId) { + return subFeaturePrivilege; + } + } +} diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts index 5693cb1bb5108..9083ba0a9ce86 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts @@ -6,13 +6,14 @@ */ import type { KibanaFeature } from '@kbn/features-plugin/server'; +import { getMinimalPrivilegeId } from '@kbn/security-authorization-core-common'; export function validateFeaturePrivileges(features: KibanaFeature[]) { for (const feature of features) { const seenPrivilegeIds = new Set(); Object.keys(feature.privileges ?? {}).forEach((privilegeId) => { seenPrivilegeIds.add(privilegeId); - seenPrivilegeIds.add(`minimal_${privilegeId}`); + seenPrivilegeIds.add(getMinimalPrivilegeId(privilegeId)); }); const subFeatureEntries = feature.subFeatures ?? []; diff --git a/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts index 8075c258f323f..62d7085ccfbc2 100644 --- a/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts +++ b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts @@ -46,14 +46,14 @@ export const getPrivilegeDeprecationsService = ({ context.esClient.asCurrentUser.security.getRole(), ]); kibanaRoles = Object.entries(elasticsearchRoles).map(([roleName, elasticsearchRole]) => - transformElasticsearchRoleToRole( + transformElasticsearchRoleToRole({ features, // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` elasticsearchRole, - roleName, - authz.applicationName, - logger - ) + name: roleName, + application: authz.applicationName, + logger, + }) ); } catch (e) { const statusCode = getErrorStatusCode(e); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index b6054478cc2c4..3007973d59b47 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -331,6 +331,7 @@ export class SecurityPlugin getSession: this.getSession, getFeatures: () => startServicesPromise.then((services) => services.features.getKibanaFeatures()), + subFeaturePrivilegeIterator: features.subFeaturePrivilegeIterator, getFeatureUsageService: this.getFeatureUsageService, getAuthenticationService: this.getAuthentication, getAnonymousAccessService: this.getAnonymousAccess, diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts index 18315bb9caf8f..9e71003358e80 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts @@ -10,7 +10,7 @@ import { coreMock, httpServerMock } from '@kbn/core/server/mocks'; import type { LicenseCheck } from '@kbn/licensing-plugin/server'; import { defineGetPrivilegesRoutes } from './get'; -import type { RawKibanaPrivileges } from '../../../../common/model'; +import type { RawKibanaPrivileges } from '../../../../common'; import type { SecurityRequestHandlerContext } from '../../../types'; import { routeDefinitionParamsMock } from '../../index.mock'; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts index b09743fd077e2..fa993b357c1f0 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts @@ -9,6 +9,8 @@ import Boom from '@hapi/boom'; import { kibanaResponseFactory } from '@kbn/core/server'; import { coreMock, httpServerMock } from '@kbn/core/server/mocks'; +import { KibanaFeature } from '@kbn/features-plugin/common'; +import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; import type { LicenseCheck } from '@kbn/licensing-plugin/server'; import { defineGetRolesRoutes } from './get'; @@ -22,17 +24,133 @@ interface TestOptions { licenseCheckResult?: LicenseCheck; apiResponse?: () => unknown; asserts: { statusCode: number; result?: Record }; + query?: Record; } +const features: KibanaFeature[] = [ + new KibanaFeature({ + deprecated: { notice: 'It is deprecated, sorry.' }, + id: 'alpha', + name: 'Feature Alpha', + app: [], + category: { id: 'alpha', label: 'alpha' }, + privileges: { + all: { + savedObject: { + all: ['all-alpha-all-so'], + read: ['all-alpha-read-so'], + }, + ui: ['all-alpha-ui'], + app: ['all-alpha-app'], + api: ['all-alpha-api'], + replacedBy: [{ feature: 'beta', privileges: ['all'] }], + }, + read: { + savedObject: { + all: ['read-alpha-all-so'], + read: ['read-alpha-read-so'], + }, + ui: ['read-alpha-ui'], + app: ['read-alpha-app'], + api: ['read-alpha-api'], + replacedBy: { + default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }], + minimal: [{ feature: 'beta', privileges: ['minimal_read'] }], + }, + }, + }, + subFeatures: [ + { + name: 'sub-feature-alpha', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_alpha', + name: 'Sub Feature Alpha', + includeIn: 'all', + savedObject: { + all: ['sub-alpha-all-so'], + read: ['sub-alpha-read-so'], + }, + ui: ['sub-alpha-ui'], + app: ['sub-alpha-app'], + api: ['sub-alpha-api'], + replacedBy: [ + { feature: 'beta', privileges: ['minimal_read'] }, + { feature: 'beta', privileges: ['sub_beta'] }, + ], + }, + ], + }, + ], + }, + ], + }), + new KibanaFeature({ + id: 'beta', + name: 'Feature Beta', + app: [], + category: { id: 'beta', label: 'beta' }, + privileges: { + all: { + savedObject: { + all: ['all-beta-all-so'], + read: ['all-beta-read-so'], + }, + ui: ['all-beta-ui'], + app: ['all-beta-app'], + api: ['all-beta-api'], + }, + read: { + savedObject: { + all: ['read-beta-all-so'], + read: ['read-beta-read-so'], + }, + ui: ['read-beta-ui'], + app: ['read-beta-app'], + api: ['read-beta-api'], + }, + }, + subFeatures: [ + { + name: 'sub-feature-beta', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_beta', + name: 'Sub Feature Beta', + includeIn: 'all', + savedObject: { + all: ['sub-beta-all-so'], + read: ['sub-beta-read-so'], + }, + ui: ['sub-beta-ui'], + app: ['sub-beta-app'], + api: ['sub-beta-api'], + }, + ], + }, + ], + }, + ], + }), +]; + describe('GET role', () => { const getRoleTest = ( description: string, - { name, licenseCheckResult = { state: 'valid' }, apiResponse, asserts }: TestOptions + { name, licenseCheckResult = { state: 'valid' }, apiResponse, asserts, query }: TestOptions ) => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); mockRouteDefinitionParams.authz.applicationName = application; - mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]); + mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue(features); + mockRouteDefinitionParams.subFeaturePrivilegeIterator = + featuresPluginMock.createSetup().subFeaturePrivilegeIterator; const mockCoreContext = coreMock.createRequestHandlerContext(); const mockLicensingContext = { @@ -54,10 +172,11 @@ describe('GET role', () => { const headers = { authorization: 'foo' }; const mockRequest = httpServerMock.createKibanaRequest({ - method: 'delete', + method: 'get', path: `/api/security/role/${name}`, params: { name }, headers, + query, }); const response = await handler(mockContext, mockRequest, kibanaResponseFactory); @@ -1158,5 +1277,76 @@ describe('GET role', () => { }, }, }); + + getRoleTest( + `preserves privileges of deprecated features as is when [replaceDeprecatedKibanaPrivileges=false]`, + { + name: 'first_role', + apiResponse: () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_alpha.read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { _reserved: true }, + transient_metadata: { enabled: true }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { _reserved: true }, + transient_metadata: { enabled: true }, + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], feature: { alpha: ['read'] }, spaces: ['*'] }], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `replaces privileges of deprecated features when [replaceDeprecatedKibanaPrivileges=true]`, + { + name: 'first_role', + query: { replaceDeprecatedPrivileges: true }, + apiResponse: () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_alpha.read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { _reserved: true }, + transient_metadata: { enabled: true }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { _reserved: true }, + transient_metadata: { enabled: true }, + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], feature: { beta: ['read', 'sub_beta'] }, spaces: ['*'] }], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + } + ); }); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.ts index ec2208341dc16..d13d2254f79d2 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.ts @@ -16,6 +16,7 @@ export function defineGetRolesRoutes({ router, authz, getFeatures, + subFeaturePrivilegeIterator, logger, }: RouteDefinitionParams) { router.get( @@ -27,6 +28,9 @@ export function defineGetRolesRoutes({ }, validate: { params: schema.object({ name: schema.string({ minLength: 1 }) }), + query: schema.maybe( + schema.object({ replaceDeprecatedPrivileges: schema.maybe(schema.boolean()) }) + ), }, }, createLicensedRouteHandler(async (context, request, response) => { @@ -44,14 +48,16 @@ export function defineGetRolesRoutes({ if (elasticsearchRole) { return response.ok({ - body: transformElasticsearchRoleToRole( + body: transformElasticsearchRoleToRole({ features, - // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` + subFeaturePrivilegeIterator, // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` elasticsearchRole, - request.params.name, - authz.applicationName, - logger - ), + name: request.params.name, + application: authz.applicationName, + logger, + replaceDeprecatedKibanaPrivileges: + request.query?.replaceDeprecatedPrivileges ?? false, + }), }); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts index 3fe91ded3342d..a3f1f264a1ce6 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts @@ -9,6 +9,8 @@ import Boom from '@hapi/boom'; import { kibanaResponseFactory } from '@kbn/core/server'; import { coreMock, httpServerMock } from '@kbn/core/server/mocks'; +import { KibanaFeature } from '@kbn/features-plugin/common'; +import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; import type { LicenseCheck } from '@kbn/licensing-plugin/server'; import { defineGetAllRolesRoutes } from './get_all'; @@ -22,17 +24,133 @@ interface TestOptions { licenseCheckResult?: LicenseCheck; apiResponse?: () => unknown; asserts: { statusCode: number; result?: Record }; + query?: Record; } +const features: KibanaFeature[] = [ + new KibanaFeature({ + deprecated: { notice: 'It is deprecated, sorry.' }, + id: 'alpha', + name: 'Feature Alpha', + app: [], + category: { id: 'alpha', label: 'alpha' }, + privileges: { + all: { + savedObject: { + all: ['all-alpha-all-so'], + read: ['all-alpha-read-so'], + }, + ui: ['all-alpha-ui'], + app: ['all-alpha-app'], + api: ['all-alpha-api'], + replacedBy: [{ feature: 'beta', privileges: ['all'] }], + }, + read: { + savedObject: { + all: ['read-alpha-all-so'], + read: ['read-alpha-read-so'], + }, + ui: ['read-alpha-ui'], + app: ['read-alpha-app'], + api: ['read-alpha-api'], + replacedBy: { + default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }], + minimal: [{ feature: 'beta', privileges: ['minimal_read'] }], + }, + }, + }, + subFeatures: [ + { + name: 'sub-feature-alpha', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_alpha', + name: 'Sub Feature Alpha', + includeIn: 'all', + savedObject: { + all: ['sub-alpha-all-so'], + read: ['sub-alpha-read-so'], + }, + ui: ['sub-alpha-ui'], + app: ['sub-alpha-app'], + api: ['sub-alpha-api'], + replacedBy: [ + { feature: 'beta', privileges: ['minimal_read'] }, + { feature: 'beta', privileges: ['sub_beta'] }, + ], + }, + ], + }, + ], + }, + ], + }), + new KibanaFeature({ + id: 'beta', + name: 'Feature Beta', + app: [], + category: { id: 'beta', label: 'beta' }, + privileges: { + all: { + savedObject: { + all: ['all-beta-all-so'], + read: ['all-beta-read-so'], + }, + ui: ['all-beta-ui'], + app: ['all-beta-app'], + api: ['all-beta-api'], + }, + read: { + savedObject: { + all: ['read-beta-all-so'], + read: ['read-beta-read-so'], + }, + ui: ['read-beta-ui'], + app: ['read-beta-app'], + api: ['read-beta-api'], + }, + }, + subFeatures: [ + { + name: 'sub-feature-beta', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_beta', + name: 'Sub Feature Beta', + includeIn: 'all', + savedObject: { + all: ['sub-beta-all-so'], + read: ['sub-beta-read-so'], + }, + ui: ['sub-beta-ui'], + app: ['sub-beta-app'], + api: ['sub-beta-api'], + }, + ], + }, + ], + }, + ], + }), +]; + describe('GET all roles', () => { const getRolesTest = ( description: string, - { licenseCheckResult = { state: 'valid' }, apiResponse, asserts }: TestOptions + { licenseCheckResult = { state: 'valid' }, apiResponse, asserts, query }: TestOptions ) => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); mockRouteDefinitionParams.authz.applicationName = application; - mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]); + mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue(features); + mockRouteDefinitionParams.subFeaturePrivilegeIterator = + featuresPluginMock.createSetup().subFeaturePrivilegeIterator; const mockCoreContext = coreMock.createRequestHandlerContext(); const mockLicensingContext = { @@ -54,9 +172,10 @@ describe('GET all roles', () => { const headers = { authorization: 'foo' }; const mockRequest = httpServerMock.createKibanaRequest({ - method: 'delete', + method: 'get', path: '/api/security/role', headers, + query, }); const response = await handler(mockContext, mockRequest, kibanaResponseFactory); @@ -1361,5 +1480,78 @@ describe('GET all roles', () => { ], }, }); + + getRolesTest( + `preserves privileges of deprecated features as is when [replaceDeprecatedKibanaPrivileges=false]`, + { + apiResponse: () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_alpha.read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { _reserved: true }, + transient_metadata: { enabled: true }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { _reserved: true }, + transient_metadata: { enabled: true }, + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], feature: { alpha: ['read'] }, spaces: ['*'] }], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `replaces privileges of deprecated features when [replaceDeprecatedKibanaPrivileges=true]`, + { + query: { replaceDeprecatedPrivileges: true }, + apiResponse: () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_alpha.read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { _reserved: true }, + transient_metadata: { enabled: true }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { _reserved: true }, + transient_metadata: { enabled: true }, + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], feature: { beta: ['read', 'sub_beta'] }, spaces: ['*'] }], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + } + ); }); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts index ed31aedba7f31..0e3004fd1e333 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { schema } from '@kbn/config-schema'; + import type { RouteDefinitionParams } from '../..'; import { compareRolesByName, transformElasticsearchRoleToRole } from '../../../authorization'; import { wrapIntoCustomErrorResponse } from '../../../errors'; @@ -14,9 +16,9 @@ export function defineGetAllRolesRoutes({ router, authz, getFeatures, + subFeaturePrivilegeIterator, logger, buildFlavor, - config, }: RouteDefinitionParams) { router.get( { @@ -25,7 +27,13 @@ export function defineGetAllRolesRoutes({ access: 'public', summary: `Get all roles`, }, - validate: false, + validate: { + request: { + query: schema.maybe( + schema.object({ replaceDeprecatedPrivileges: schema.maybe(schema.boolean()) }) + ), + }, + }, }, createLicensedRouteHandler(async (context, request, response) => { try { @@ -40,14 +48,16 @@ export function defineGetAllRolesRoutes({ return response.ok({ body: Object.entries(elasticsearchRoles) .map(([roleName, elasticsearchRole]) => - transformElasticsearchRoleToRole( + transformElasticsearchRoleToRole({ features, - // @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[] + subFeaturePrivilegeIterator, // @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[] elasticsearchRole, - roleName, - authz.applicationName, - logger - ) + name: roleName, + application: authz.applicationName, + logger, + replaceDeprecatedKibanaPrivileges: + request.query?.replaceDeprecatedPrivileges ?? false, + }) ) .filter((role) => { return !hideReservedRoles || !role.metadata?._reserved; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.test.ts index f918fa983c701..5797948244dad 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.test.ts @@ -8,6 +8,8 @@ import Boom from '@hapi/boom'; import { kibanaResponseFactory } from '@kbn/core/server'; import { coreMock, httpServerMock } from '@kbn/core/server/mocks'; +import { KibanaFeature } from '@kbn/features-plugin/common'; +import { featuresPluginMock } from '@kbn/features-plugin/server/mocks'; import type { LicenseCheck } from '@kbn/licensing-plugin/server'; import { defineGetAllRolesBySpaceRoutes } from './get_all_by_space'; @@ -23,6 +25,119 @@ interface TestOptions { spaceId?: string; } +const features: KibanaFeature[] = [ + new KibanaFeature({ + deprecated: { notice: 'It is deprecated, sorry.' }, + id: 'alpha', + name: 'Feature Alpha', + app: [], + category: { id: 'alpha', label: 'alpha' }, + privileges: { + all: { + savedObject: { + all: ['all-alpha-all-so'], + read: ['all-alpha-read-so'], + }, + ui: ['all-alpha-ui'], + app: ['all-alpha-app'], + api: ['all-alpha-api'], + replacedBy: [{ feature: 'beta', privileges: ['all'] }], + }, + read: { + savedObject: { + all: ['read-alpha-all-so'], + read: ['read-alpha-read-so'], + }, + ui: ['read-alpha-ui'], + app: ['read-alpha-app'], + api: ['read-alpha-api'], + replacedBy: { + default: [{ feature: 'beta', privileges: ['read', 'sub_beta'] }], + minimal: [{ feature: 'beta', privileges: ['minimal_read'] }], + }, + }, + }, + subFeatures: [ + { + name: 'sub-feature-alpha', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_alpha', + name: 'Sub Feature Alpha', + includeIn: 'all', + savedObject: { + all: ['sub-alpha-all-so'], + read: ['sub-alpha-read-so'], + }, + ui: ['sub-alpha-ui'], + app: ['sub-alpha-app'], + api: ['sub-alpha-api'], + replacedBy: [ + { feature: 'beta', privileges: ['minimal_read'] }, + { feature: 'beta', privileges: ['sub_beta'] }, + ], + }, + ], + }, + ], + }, + ], + }), + new KibanaFeature({ + id: 'beta', + name: 'Feature Beta', + app: [], + category: { id: 'beta', label: 'beta' }, + privileges: { + all: { + savedObject: { + all: ['all-beta-all-so'], + read: ['all-beta-read-so'], + }, + ui: ['all-beta-ui'], + app: ['all-beta-app'], + api: ['all-beta-api'], + }, + read: { + savedObject: { + all: ['read-beta-all-so'], + read: ['read-beta-read-so'], + }, + ui: ['read-beta-ui'], + app: ['read-beta-app'], + api: ['read-beta-api'], + }, + }, + subFeatures: [ + { + name: 'sub-feature-beta', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_beta', + name: 'Sub Feature Beta', + includeIn: 'all', + savedObject: { + all: ['sub-beta-all-so'], + read: ['sub-beta-read-so'], + }, + ui: ['sub-beta-ui'], + app: ['sub-beta-app'], + api: ['sub-beta-api'], + }, + ], + }, + ], + }, + ], + }), +]; + describe('GET all roles by space id', () => { it('correctly defines route.', () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); @@ -50,7 +165,9 @@ describe('GET all roles by space id', () => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); mockRouteDefinitionParams.authz.applicationName = application; - mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]); + mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue(features); + mockRouteDefinitionParams.subFeaturePrivilegeIterator = + featuresPluginMock.createSetup().subFeaturePrivilegeIterator; const mockCoreContext = coreMock.createRequestHandlerContext(); const mockLicensingContext = { @@ -397,4 +514,37 @@ describe('GET all roles by space id', () => { }, }); }); + + getRolesTest(`replaces privileges of deprecated features by default`, { + apiResponse: () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['feature_alpha.read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { _reserved: true }, + transient_metadata: { enabled: true }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { _reserved: true }, + transient_metadata: { enabled: true }, + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ base: [], feature: { beta: ['read', 'sub_beta'] }, spaces: ['*'] }], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + }); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.ts index 9cfdf3ba301ac..48ec8e8f72461 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all_by_space.ts @@ -19,7 +19,7 @@ export function defineGetAllRolesBySpaceRoutes({ getFeatures, logger, buildFlavor, - config, + subFeaturePrivilegeIterator, }: RouteDefinitionParams) { router.get( { @@ -49,14 +49,17 @@ export function defineGetAllRolesBySpaceRoutes({ return acc; } - const role = transformElasticsearchRoleToRole( + const role = transformElasticsearchRoleToRole({ features, // @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[] elasticsearchRole, - roleName, - authz.applicationName, - logger - ); + name: roleName, + application: authz.applicationName, + logger, + subFeaturePrivilegeIterator, + // For the internal APIs we always transform deprecated privileges. + replaceDeprecatedKibanaPrivileges: true, + }); const includeRoleForSpace = role.kibana.some((privilege) => { const privilegeInSpace = diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 212cf6768d1aa..8b986cc4a3893 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -10,6 +10,7 @@ import type { Observable } from 'rxjs'; import type { BuildFlavor } from '@kbn/config/src/types'; import type { HttpResources, IBasePath, Logger } from '@kbn/core/server'; import type { KibanaFeature } from '@kbn/features-plugin/server'; +import type { SubFeaturePrivilegeIterator } from '@kbn/features-plugin/server/feature_privilege_iterator'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { defineAnalyticsRoutes } from './analytics'; @@ -51,6 +52,7 @@ export interface RouteDefinitionParams { getSession: () => PublicMethodsOf; license: SecurityLicense; getFeatures: () => Promise; + subFeaturePrivilegeIterator: SubFeaturePrivilegeIterator; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; getAuthenticationService: () => InternalAuthenticationServiceStart; getUserProfileService: () => UserProfileServiceStartInternal; diff --git a/x-pack/plugins/security/tsconfig.json b/x-pack/plugins/security/tsconfig.json index f9c08c93b2163..5d40e1247710b 100644 --- a/x-pack/plugins/security/tsconfig.json +++ b/x-pack/plugins/security/tsconfig.json @@ -85,6 +85,7 @@ "@kbn/security-authorization-core", "@kbn/security-role-management-model", "@kbn/security-ui-components", + "@kbn/security-authorization-core-common", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx index fcb9f8ad2b8cc..f33c2cba25268 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx @@ -31,8 +31,11 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { KibanaFeature, KibanaFeatureConfig } from '@kbn/features-plugin/common'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { type RawKibanaPrivileges } from '@kbn/security-authorization-core'; -import type { Role, RoleKibanaPrivilege } from '@kbn/security-plugin-types-common'; +import type { + RawKibanaPrivileges, + Role, + RoleKibanaPrivilege, +} from '@kbn/security-plugin-types-common'; import type { BulkUpdateRoleResponse } from '@kbn/security-plugin-types-public/src/roles/roles_api_client'; import { KibanaPrivileges } from '@kbn/security-role-management-model'; import { KibanaPrivilegeTable, PrivilegeFormCalculator } from '@kbn/security-ui-components'; diff --git a/x-pack/plugins/spaces/tsconfig.json b/x-pack/plugins/spaces/tsconfig.json index 20d3f7d3175d8..0ef36746928b2 100644 --- a/x-pack/plugins/spaces/tsconfig.json +++ b/x-pack/plugins/spaces/tsconfig.json @@ -48,7 +48,6 @@ "@kbn/react-kibana-mount", "@kbn/shared-ux-utility", "@kbn/core-application-common", - "@kbn/security-authorization-core", "@kbn/core-notifications-browser", "@kbn/logging", "@kbn/core-logging-browser-mocks", diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 8db8c08b16728..51ce417cfe695 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -8,7 +8,7 @@ import util from 'util'; import { isEqual, isEqualWith } from 'lodash'; import expect from '@kbn/expect'; -import { RawKibanaPrivileges } from '@kbn/security-plugin/common/model'; +import { RawKibanaPrivileges } from '@kbn/security-plugin-types-common'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { @@ -112,7 +112,7 @@ export default function ({ getService }: FtrProviderContext) { advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'], indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'], - savedQueryManagement: ['all', 'minimal_all'], + savedQueryManagement: ['all', 'read', 'minimal_all', 'minimal_read'], osquery: [ 'all', 'read', diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index a82ecc2aa4fd6..dda148359ac16 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -27,7 +27,7 @@ export default function ({ getService }: FtrProviderContext) { advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'], indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'], - savedQueryManagement: ['all', 'minimal_all'], + savedQueryManagement: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'], graph: ['all', 'read', 'minimal_all', 'minimal_read'], maps: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -201,7 +201,7 @@ export default function ({ getService }: FtrProviderContext) { filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'], filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'], savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'], - savedQueryManagement: ['all', 'minimal_all'], + savedQueryManagement: ['all', 'read', 'minimal_all', 'minimal_read'], osquery: [ 'all', 'read', diff --git a/x-pack/test/security_api_integration/features.config.ts b/x-pack/test/security_api_integration/features.config.ts new file mode 100644 index 0000000000000..3c3300a071fc5 --- /dev/null +++ b/x-pack/test/security_api_integration/features.config.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { resolve } from 'path'; + +import type { FtrConfigProviderContext } from '@kbn/test'; + +import { services } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + const featuresProviderPlugin = resolve(__dirname, './plugins/features_provider'); + + return { + testFiles: [require.resolve('./tests/features')], + servers: xPackAPITestsConfig.get('servers'), + security: { disableTestUser: true }, + services, + junit: { + reportName: 'X-Pack Security API Integration Tests (Features)', + }, + + esTestCluster: xPackAPITestsConfig.get('esTestCluster'), + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${featuresProviderPlugin}`, + ], + }, + }; +} diff --git a/x-pack/test/security_api_integration/plugins/features_provider/kibana.jsonc b/x-pack/test/security_api_integration/plugins/features_provider/kibana.jsonc new file mode 100644 index 0000000000000..c832ea1104d83 --- /dev/null +++ b/x-pack/test/security_api_integration/plugins/features_provider/kibana.jsonc @@ -0,0 +1,14 @@ +{ + "type": "plugin", + "id": "@kbn/features-provider-plugin", + "owner": "@elastic/kibana-security", + "plugin": { + "id": "featuresProviderPlugin", + "server": true, + "browser": false, + "requiredPlugins": [ + "alerting", + "features" + ] + } +} diff --git a/x-pack/test/security_api_integration/plugins/features_provider/server/index.ts b/x-pack/test/security_api_integration/plugins/features_provider/server/index.ts new file mode 100644 index 0000000000000..646fe327a0015 --- /dev/null +++ b/x-pack/test/security_api_integration/plugins/features_provider/server/index.ts @@ -0,0 +1,375 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginSetupContract as AlertingPluginsSetup } from '@kbn/alerting-plugin/server/plugin'; +import { schema } from '@kbn/config-schema'; +import type { CoreSetup, Plugin, PluginInitializer } from '@kbn/core/server'; +import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; +import type { FeaturesPluginSetup, FeaturesPluginStart } from '@kbn/features-plugin/server'; + +export interface PluginSetupDependencies { + features: FeaturesPluginSetup; + alerting: AlertingPluginsSetup; +} + +export interface PluginStartDependencies { + features: FeaturesPluginStart; +} + +export const plugin: PluginInitializer = async (): Promise< + Plugin +> => ({ + setup: (_: CoreSetup, deps: PluginSetupDependencies) => { + // Case #1: feature A needs to be renamed to feature B. It's unfortunate, but the existing feature A + // should be deprecated and re-created as a new feature with the same privileges. + case1FeatureRename(deps); + + // Case #2: feature A needs to be split into two separate features B and C. In this case we mark + // feature as deprecated and create two new features. + case2FeatureSplit(deps); + + // Case #3: feature A grants access to Saved Object types `one` and `two` via top-level `all` + // and `read` privileges. The requirement is to not grant access to `two` via top-level + // privileges, and instead use sub-feature privilege for that. + case3FeatureSplitSubFeature(deps); + + // Case #4: features A (`case_4_feature_a`) and B (`case_4_feature_b`) grant access to the saved object type `ab`. + // The requirement is to introduce a new feature C (`case_4_feature_c) that will grant access to `ab`, and remove + // this privilege from feature A and B. Here's what we'll have as the result: + // * `case_4_feature_a` (existing, deprecated) + // * `case_4_feature_b` (existing, deprecated) + // * `case_4_feature_a_v2` (new, decoupled from `ab` SO, partially replaces `case_4_feature_a`) + // * `case_4_feature_b_v2` (new, decoupled from `ab` SO, partially replaces `case_4_feature_b`) + // * `case_4_feature_c` (new, only for `ab` SO access) + case4FeatureExtract(deps); + }, + start: () => {}, + stop: () => {}, +}); + +function case1FeatureRename(deps: PluginSetupDependencies) { + // Step 1: extract a part of the feature definition that will be shared between deprecated and new + // features. + const commonFeatureDefinition = { + app: [], + category: DEFAULT_APP_CATEGORIES.kibana, + privileges: { + all: { savedObject: { all: ['one'], read: [] }, ui: ['ui_all'] }, + read: { savedObject: { all: [], read: ['one'] }, ui: ['ui_read'] }, + }, + }; + + // Step 2: mark feature A as deprecated and provide proper replacements for all feature and + // sub-feature privileges. + deps.features.registerKibanaFeature({ + ...commonFeatureDefinition, + deprecated: { notice: 'Case #1 is deprecated.' }, + id: 'case_1_feature_a', + name: 'Case #1 feature A (DEPRECATED)', + privileges: { + all: { + ...commonFeatureDefinition.privileges.all, + replacedBy: [{ feature: 'case_1_feature_b', privileges: ['all'] }], + }, + read: { + ...commonFeatureDefinition.privileges.read, + replacedBy: [{ feature: 'case_1_feature_b', privileges: ['read'] }], + }, + }, + }); + + // Step 3: define a new feature with exactly same privileges. + deps.features.registerKibanaFeature({ + ...commonFeatureDefinition, + id: 'case_1_feature_b', + name: 'Case #1 feature B', + }); +} + +function case2FeatureSplit(deps: PluginSetupDependencies) { + // Step 1: mark feature A as deprecated and provide proper replacements for all feature and + // sub-feature privileges. + deps.features.registerKibanaFeature({ + deprecated: { notice: 'Case #2 is deprecated.' }, + + app: ['app_one', 'app_two'], + catalogue: ['cat_one', 'cat_two'], + management: { kibana: ['management_one', 'management_two'] }, + category: DEFAULT_APP_CATEGORIES.kibana, + id: 'case_2_feature_a', + name: 'Case #2 feature A (DEPRECATED)', + alerting: ['alerting_rule_type_one', 'alerting_rule_type_two'], + cases: ['cases_owner_one', 'cases_owner_two'], + privileges: { + all: { + savedObject: { all: ['one', 'two'], read: [] }, + ui: ['ui_all_one', 'ui_all_two'], + api: ['api_one', 'api_two'], + app: ['app_one', 'app_two'], + catalogue: ['cat_one', 'cat_two'], + management: { kibana: ['management_one', 'management_two'] }, + alerting: { + rule: { + all: ['alerting_rule_type_one', 'alerting_rule_type_two'], + read: ['alerting_rule_type_one', 'alerting_rule_type_two'], + }, + alert: { + all: ['alerting_rule_type_one', 'alerting_rule_type_two'], + read: ['alerting_rule_type_one', 'alerting_rule_type_two'], + }, + }, + cases: { + all: ['cases_owner_one', 'cases_owner_two'], + push: ['cases_owner_one', 'cases_owner_two'], + create: ['cases_owner_one', 'cases_owner_two'], + read: ['cases_owner_one', 'cases_owner_two'], + update: ['cases_owner_one', 'cases_owner_two'], + delete: ['cases_owner_one', 'cases_owner_two'], + settings: ['cases_owner_one', 'cases_owner_two'], + }, + replacedBy: [ + { feature: 'case_2_feature_b', privileges: ['all'] }, + { feature: 'case_2_feature_c', privileges: ['all'] }, + ], + }, + read: { + savedObject: { all: [], read: ['one', 'two'] }, + ui: ['ui_read_one', 'ui_read_two'], + replacedBy: [ + { feature: 'case_2_feature_b', privileges: ['read'] }, + { feature: 'case_2_feature_c', privileges: ['read'] }, + ], + }, + }, + }); + + // Step 2: define new features + deps.features.registerKibanaFeature({ + category: DEFAULT_APP_CATEGORIES.kibana, + id: 'case_2_feature_b', + name: 'Case #2 feature B', + app: ['app_one'], + catalogue: ['cat_one'], + management: { kibana: ['management_one'] }, + alerting: ['alerting_rule_type_one'], + cases: ['cases_owner_one'], + privileges: { + all: { + savedObject: { all: ['one'], read: [] }, + ui: ['ui_all_one'], + api: ['api_one'], + app: ['app_one'], + catalogue: ['cat_one'], + management: { kibana: ['management_one'] }, + alerting: { + rule: { all: ['alerting_rule_type_one'], read: ['alerting_rule_type_one'] }, + alert: { all: ['alerting_rule_type_one'], read: ['alerting_rule_type_one'] }, + }, + cases: { + all: ['cases_owner_one'], + push: ['cases_owner_one'], + create: ['cases_owner_one'], + read: ['cases_owner_one'], + update: ['cases_owner_one'], + delete: ['cases_owner_one'], + settings: ['cases_owner_one'], + }, + }, + read: { + savedObject: { all: [], read: ['one'] }, + ui: ['ui_read_one'], + }, + }, + }); + deps.features.registerKibanaFeature({ + category: DEFAULT_APP_CATEGORIES.kibana, + id: 'case_2_feature_c', + name: 'Case #2 feature C', + app: ['app_two'], + catalogue: ['cat_two'], + management: { kibana: ['management_two'] }, + alerting: ['alerting_rule_type_two'], + cases: ['cases_owner_two'], + privileges: { + all: { + savedObject: { all: ['two'], read: [] }, + ui: ['ui_all_two'], + api: ['api_two'], + app: ['app_two'], + catalogue: ['cat_two'], + management: { kibana: ['management_two'] }, + alerting: { + rule: { all: ['alerting_rule_type_two'], read: ['alerting_rule_type_two'] }, + alert: { all: ['alerting_rule_type_two'], read: ['alerting_rule_type_two'] }, + }, + cases: { + all: ['cases_owner_two'], + push: ['cases_owner_two'], + create: ['cases_owner_two'], + read: ['cases_owner_two'], + update: ['cases_owner_two'], + delete: ['cases_owner_two'], + settings: ['cases_owner_two'], + }, + }, + read: { + savedObject: { all: [], read: ['two'] }, + ui: ['ui_read_two'], + }, + }, + }); + + // Register alerting rule types used in a deprecated feature. + for (const [id, producer] of [ + ['alerting_rule_type_one', 'case_2_feature_a'], + ['alerting_rule_type_two', 'case_2_feature_a'], + ]) { + deps.alerting.registerType({ + id, + name: `${id}-${producer} name`, + actionGroups: [{ id: 'default', name: 'Default' }], + category: 'kibana', + producer, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + executor: () => Promise.resolve({ state: {} }), + validate: { params: schema.any() }, + }); + } +} + +function case3FeatureSplitSubFeature(deps: PluginSetupDependencies) { + // Step 1: mark feature A as deprecated and provide proper replacements for all feature and + // sub-feature privileges. + deps.features.registerKibanaFeature({ + deprecated: { notice: 'Case #3 is deprecated.' }, + + category: DEFAULT_APP_CATEGORIES.kibana, + id: 'case_3_feature_a', + name: 'Case #3 feature A (DEPRECATED)', + app: [], + privileges: { + all: { + savedObject: { all: ['one', 'two'], read: [] }, + ui: [], + // Since `case_3_feature_a_v2.so_two_all` isn't automatically included in `case_3_feature_a_v2.all`, + // we should map to both minimal `all` privilege and sub-feature privilege. + replacedBy: [{ feature: 'case_3_feature_a_v2', privileges: ['minimal_all', 'so_two_all'] }], + }, + read: { + savedObject: { all: [], read: ['one', 'two'] }, + ui: [], + replacedBy: [ + // Since `case_3_feature_a_v2.so_two_read` isn't automatically included in `case_3_feature_a_v2.read`, + // we should map to both minimal `read` privilege and sub-feature privilege. + { feature: 'case_3_feature_a_v2', privileges: ['minimal_read', 'so_two_read'] }, + ], + }, + }, + }); + + // Step 2: Create a new feature with the desired privileges structure. + deps.features.registerKibanaFeature({ + category: DEFAULT_APP_CATEGORIES.kibana, + id: 'case_3_feature_a_v2', + name: 'Case #3 feature A', + app: [], + privileges: { + all: { + savedObject: { all: ['one'], read: [] }, + ui: [], + }, + read: { + savedObject: { all: [], read: ['one'] }, + ui: [], + }, + }, + subFeatures: [ + { + name: 'Access to SO `two`', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'so_two_all', + includeIn: 'none', + name: 'All', + savedObject: { all: ['two'], read: [] }, + ui: [], + }, + { + id: 'so_two_read', + includeIn: 'none', + name: 'Read', + savedObject: { all: [], read: ['two'] }, + ui: [], + }, + ], + }, + ], + }, + ], + }); +} + +function case4FeatureExtract(deps: PluginSetupDependencies) { + for (const suffix of ['A', 'B']) { + // Step 1: mark existing feature A and feature B as deprecated. + deps.features.registerKibanaFeature({ + deprecated: { notice: 'Case #4 is deprecated.' }, + + category: DEFAULT_APP_CATEGORIES.kibana, + id: `case_4_feature_${suffix.toLowerCase()}`, + name: `Case #4 feature ${suffix} (DEPRECATED)`, + app: [], + privileges: { + all: { + savedObject: { all: ['ab'], read: [] }, + ui: ['ui_all'], + replacedBy: [ + { feature: `case_4_feature_${suffix.toLowerCase()}_v2`, privileges: ['all'] }, + { feature: `case_4_feature_c`, privileges: ['all'] }, + ], + }, + read: { + savedObject: { all: [], read: ['ab'] }, + ui: ['ui_read'], + replacedBy: [ + { feature: `case_4_feature_${suffix.toLowerCase()}_v2`, privileges: ['read'] }, + { feature: `case_4_feature_c`, privileges: ['all'] }, + ], + }, + }, + }); + + // Step 2: introduce new features (v2) with privileges that don't grant access to `ab`. + deps.features.registerKibanaFeature({ + category: DEFAULT_APP_CATEGORIES.kibana, + id: `case_4_feature_${suffix.toLowerCase()}_v2`, + name: `Case #4 feature ${suffix}`, + app: [], + privileges: { + all: { savedObject: { all: [], read: [] }, ui: ['ui_all'] }, + read: { savedObject: { all: [], read: [] }, ui: ['ui_read'] }, + }, + }); + } + + // Step 3: introduce new feature C that only grants access to `ab`. + deps.features.registerKibanaFeature({ + category: DEFAULT_APP_CATEGORIES.kibana, + id: 'case_4_feature_c', + name: 'Case #4 feature C', + app: [], + privileges: { + all: { savedObject: { all: ['ab'], read: [] }, ui: ['ui_all'] }, + read: { savedObject: { all: [], read: ['ab'] }, ui: ['ui_read'] }, + }, + }); +} diff --git a/x-pack/test/security_api_integration/plugins/features_provider/tsconfig.json b/x-pack/test/security_api_integration/plugins/features_provider/tsconfig.json new file mode 100644 index 0000000000000..a79a8b401d990 --- /dev/null +++ b/x-pack/test/security_api_integration/plugins/features_provider/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ], + "exclude": [ + "target/**/*", + ], + "kbn_references": [ + "@kbn/core", + "@kbn/features-plugin", + "@kbn/alerting-plugin", + "@kbn/config-schema", + ] +} diff --git a/x-pack/test/security_api_integration/tests/features/deprecated_features.ts b/x-pack/test/security_api_integration/tests/features/deprecated_features.ts new file mode 100644 index 0000000000000..030f5ac704d8b --- /dev/null +++ b/x-pack/test/security_api_integration/tests/features/deprecated_features.ts @@ -0,0 +1,453 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expect } from 'expect'; + +import type { Case, CasePostRequest } from '@kbn/cases-plugin/common'; +import { CaseSeverity, ConnectorTypes } from '@kbn/cases-plugin/common'; +import type { CasesFindResponse } from '@kbn/cases-plugin/common/types/api'; +import type { + FeatureKibanaPrivilegesReference, + KibanaFeatureConfig, +} from '@kbn/features-plugin/common'; +import type { Role } from '@kbn/security-plugin-types-common'; + +import type { FtrProviderContext } from '../../ftr_provider_context'; + +function collectReplacedByForFeaturePrivileges( + feature: KibanaFeatureConfig +): Array<[string, readonly FeatureKibanaPrivilegesReference[]]> { + const privilegesToReplace = [] as Array<[string, readonly FeatureKibanaPrivilegesReference[]]>; + if (feature.privileges) { + const allReplacedBy = feature.privileges.all.replacedBy ?? []; + const readReplacedBy = feature.privileges.read.replacedBy ?? []; + privilegesToReplace.push([ + 'all', + 'default' in allReplacedBy ? allReplacedBy.default : allReplacedBy, + ]); + privilegesToReplace.push([ + 'minimal_all', + 'minimal' in allReplacedBy ? allReplacedBy.minimal : allReplacedBy, + ]); + privilegesToReplace.push([ + 'read', + 'default' in readReplacedBy ? readReplacedBy.default : readReplacedBy, + ]); + privilegesToReplace.push([ + 'minimal_read', + 'minimal' in readReplacedBy ? readReplacedBy.minimal : readReplacedBy, + ]); + } + + for (const subFeature of feature.subFeatures ?? []) { + for (const group of subFeature.privilegeGroups) { + for (const subFeaturePrivilege of group.privileges) { + privilegesToReplace.push([subFeaturePrivilege.id, subFeaturePrivilege.replacedBy ?? []]); + } + } + } + + return privilegesToReplace; +} + +function getActionsToReplace(actions: string[]) { + // The `alerting:`-prefixed actions are special since they are prefixed with a feature ID, and do + // not need to be replaced like any other privileges. + return actions.filter((action) => !action.startsWith('alerting:')); +} + +function getUserCredentials(username: string) { + return `Basic ${Buffer.from(`${username}:changeme`).toString('base64')}`; +} + +export default function ({ getService }: FtrProviderContext) { + describe('deprecated features', function () { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const log = getService('log'); + const security = getService('security'); + + before(async () => { + // Create role with deprecated feature privilege. + await security.role.create('case_2_a_deprecated', { + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ spaces: ['*'], base: [], feature: { case_2_feature_a: ['all'] } }], + }); + + // Fetch the _transformed_ deprecated role and use it to create a new role. + const { elasticsearch, kibana } = (await security.role.get('case_2_a_deprecated', { + replaceDeprecatedPrivileges: true, + })) as Role; + expect(kibana).toEqual([ + { + spaces: ['*'], + base: [], + feature: { case_2_feature_b: ['all'], case_2_feature_c: ['all'] }, + }, + ]); + await security.role.create('case_2_a_transformed', { elasticsearch, kibana }); + + // Create roles with the privileges that are supposed to replace deprecated privilege. + await security.role.create('case_2_b_new', { + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ spaces: ['*'], base: [], feature: { case_2_feature_b: ['all'] } }], + }); + await security.role.create('case_2_c_new', { + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana: [{ spaces: ['*'], base: [], feature: { case_2_feature_c: ['all'] } }], + }); + + await security.user.create('case_2_a_deprecated', { + password: 'changeme', + roles: ['case_2_a_deprecated'], + full_name: 'Deprecated', + }); + + await security.user.create('case_2_a_transformed', { + password: 'changeme', + roles: ['case_2_a_transformed'], + full_name: 'Transformed', + }); + + await security.user.create('case_2_b_new', { + password: 'changeme', + roles: ['case_2_b_new'], + full_name: 'New B', + }); + + await security.user.create('case_2_c_new', { + password: 'changeme', + roles: ['case_2_c_new'], + full_name: 'New C', + }); + }); + + after(async () => { + // Cleanup roles and users. + await Promise.all([ + security.role.delete('case_2_a_deprecated'), + security.role.delete('case_2_a_transformed'), + security.role.delete('case_2_b_new'), + security.role.delete('case_2_c_new'), + security.user.delete('case_2_a_deprecated'), + security.user.delete('case_2_a_transformed'), + security.user.delete('case_2_b_new'), + security.user.delete('case_2_c_new'), + ]); + + // Cleanup cases. + const { body: cases } = await supertest + .get(`/api/cases/_find`) + .set('kbn-xsrf', 'xxx') + .expect(200); + const casesIds = (cases as CasesFindResponse).cases.map((c) => c.id); + if (casesIds.length > 0) { + await supertest + .delete(`/api/cases`) + // we need to json stringify here because just passing in the array of case IDs will cause a 400 with Kibana + // not being able to parse the array correctly. The format ids=["1", "2"] seems to work, which stringify outputs. + .query({ ids: JSON.stringify(casesIds) }) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + } + + // Cleanup alerting rules. + const { body: rules } = await supertest.get(`/api/alerting/rules/_find`).expect(200); + for (const rule of rules.data) { + await supertest.delete(`/api/alerting/rule/${rule.id}`).set('kbn-xsrf', 'true').expect(204); + } + }); + + it('all privileges of the deprecated features should have a proper replacement', async () => { + // Fetch all features first. + const featuresResponse = await supertest.get('/api/features').expect(200); + const features = featuresResponse.body as KibanaFeatureConfig[]; + + // Collect all deprecated features. + const deprecatedFeatures = features.filter((f) => f.deprecated); + log.info(`Found ${deprecatedFeatures.length} deprecated features.`); + + // Fetch all feature privileges registered as Elasticsearch application privileges. + const privilegesResponse = await supertest + .get('/api/security/privileges?includeActions=true') + .expect(200); + const featurePrivilegesAndActions = privilegesResponse.body.features as Record< + string, + Record + >; + + // Ensure that all deprecated features registered their privileges as Elasticsearch application privileges. + for (const feature of deprecatedFeatures) { + const privilegeReplacedBy = collectReplacedByForFeaturePrivileges(feature); + for (const [privilegeId, replacedBy] of privilegeReplacedBy) { + log.debug( + `Verifying that deprecated "${feature.id}" feature has registered "${privilegeId}" privilege in Elasticsearch.` + ); + + // Capture all actions from the deprecated feature that need to be replaced. + const deprecatedActions = getActionsToReplace( + featurePrivilegesAndActions[feature.id]?.[privilegeId] ?? [] + ); + + // Capture all actions that will replace the deprecated actions. + const replacementActions = new Set( + replacedBy.flatMap(({ feature: featureId, privileges }) => + privileges.flatMap((privilege) => + getActionsToReplace(featurePrivilegesAndActions[featureId]?.[privilege] ?? []) + ) + ) + ); + log.debug( + `Privilege "${privilegeId}" of the deprecated feature "${feature.id}" has ${deprecatedActions.length} actions that will be replaced with ${replacementActions.size} actions.` + ); + + for (const deprecatedAction of deprecatedActions) { + if (!replacementActions.has(deprecatedAction)) { + throw new Error( + `Action "${deprecatedAction}" granted by the privilege "${privilegeId}" of the deprecated feature "${feature.id}" is not properly replaced.` + ); + } + } + } + } + }); + + it('replaced UI actions are properly set for deprecated privileges', async () => { + const { body: capabilities } = await supertestWithoutAuth + .post('/api/core/capabilities') + .set('Authorization', getUserCredentials('case_2_a_deprecated')) + .set('kbn-xsrf', 'xxx') + .send({ applications: [] }) + .expect(200); + + // Both deprecated and new UI capabilities should be toggled. + expect(capabilities).toEqual( + expect.objectContaining({ + // UI flags from the deprecated feature privilege. + case_2_feature_a: { + ui_all_one: true, + ui_all_two: true, + ui_read_one: false, + ui_read_two: false, + }, + + // UI flags from the feature privileges that replace deprecated one. + case_2_feature_b: { ui_all_one: true, ui_read_one: false }, + case_2_feature_c: { ui_all_two: true, ui_read_two: false }, + }) + ); + }); + + it('Cases privileges are properly handled for deprecated privileges', async () => { + const createCase = async ( + authorization: string, + props: Partial = {} + ): Promise => { + const caseRequest: CasePostRequest = { + description: 'This is a case created by a user with deprecated privilege.', + title: 'case_2_a_deprecated', + tags: ['defacement'], + severity: CaseSeverity.LOW, + connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, + settings: { syncAlerts: true }, + owner: 'cases_owner_one', + assignees: [], + ...props, + }; + + const { body: newCase } = await supertestWithoutAuth + .post('/api/cases') + .set('Authorization', authorization) + .set('kbn-xsrf', 'xxx') + .send(caseRequest) + .expect(200); + return newCase; + }; + + const getCase = async (authorization: string, caseId: string): Promise => { + const { body } = await supertestWithoutAuth + .get(`/api/cases/_find`) + .set('Authorization', authorization) + .set('kbn-xsrf', 'xxx') + .expect(200); + return (body as CasesFindResponse).cases.find((c) => c.id === caseId); + }; + + // Create cases as user with deprecated privilege. + const deprecatedUser = getUserCredentials('case_2_a_deprecated'); + const caseOneDeprecated = await createCase(deprecatedUser, { + title: 'case_2_a_deprecated_one', + owner: 'cases_owner_one', + }); + const caseTwoDeprecated = await createCase(deprecatedUser, { + title: 'case_2_a_deprecated_two', + owner: 'cases_owner_two', + }); + + // Create cases as user with transformed privileges (should be able to create cases for both + // owners). + const transformedUser = getUserCredentials('case_2_a_transformed'); + const caseOneTransformed = await createCase(transformedUser, { + title: 'case_2_a_transformed_one', + owner: 'cases_owner_one', + }); + const caseTwoTransformed = await createCase(transformedUser, { + title: 'case_2_a_transformed_two', + owner: 'cases_owner_two', + }); + + // Create cases as user with new privileges (B). + const newUserB = getUserCredentials('case_2_b_new'); + const caseOneNewB = await createCase(newUserB, { + title: 'case_2_b_new_one', + owner: 'cases_owner_one', + }); + + // Create cases as user with new privileges (C). + const newUserC = getUserCredentials('case_2_c_new'); + const caseTwoNewC = await createCase(newUserC, { + title: 'case_2_c_new_two', + owner: 'cases_owner_two', + }); + + // Users with deprecated and transformed privileges should have the same privilege level and + // be able to access cases created by themselves and users with new privileges. + for (const caseToCheck of [ + caseOneDeprecated, + caseTwoDeprecated, + caseOneTransformed, + caseTwoTransformed, + caseOneNewB, + caseTwoNewC, + ]) { + expect(await getCase(deprecatedUser, caseToCheck.id)).toBeDefined(); + expect(await getCase(transformedUser, caseToCheck.id)).toBeDefined(); + } + + // User B and User C should be able to access cases created by themselves and users with + // deprecated and transformed privileges, but only for the specific owner. + for (const caseToCheck of [caseOneDeprecated, caseOneTransformed, caseOneNewB]) { + expect(await getCase(newUserB, caseToCheck.id)).toBeDefined(); + expect(await getCase(newUserC, caseToCheck.id)).toBeUndefined(); + } + for (const caseToCheck of [caseTwoDeprecated, caseTwoTransformed, caseTwoNewC]) { + expect(await getCase(newUserC, caseToCheck.id)).toBeDefined(); + expect(await getCase(newUserB, caseToCheck.id)).toBeUndefined(); + } + }); + + it('Alerting privileges are properly handled for deprecated privileges', async () => { + const createRule = async ( + authorization: string, + name: string, + consumer: string, + ruleType: string + ): Promise<{ id: string }> => { + const { body: newRule } = await supertestWithoutAuth + .post('/api/alerting/rule') + .set('Authorization', authorization) + .set('kbn-xsrf', 'xxx') + .send({ + enabled: true, + name, + tags: ['foo'], + rule_type_id: ruleType, + consumer, + schedule: { interval: '24h' }, + throttle: undefined, + notify_when: undefined, + actions: [], + params: {}, + }) + .expect(200); + return newRule; + }; + + const getRule = async (authorization: string, ruleId: string): Promise => { + const { body } = await supertestWithoutAuth + .get(`/api/alerting/rules/_find`) + .set('Authorization', authorization) + .set('kbn-xsrf', 'xxx') + .expect(200); + return body.data.find((r: { id: string }) => r.id === ruleId); + }; + + // Create rules as user with deprecated privilege. + const deprecatedUser = getUserCredentials('case_2_a_deprecated'); + const ruleOneDeprecated = await createRule( + deprecatedUser, + 'case_2_a_deprecated_one', + 'case_2_feature_a', + 'alerting_rule_type_one' + ); + const ruleTwoDeprecated = await createRule( + deprecatedUser, + 'case_2_a_deprecated_two', + 'case_2_feature_a', + 'alerting_rule_type_two' + ); + + // Create rules as user with transformed privileges (should be able to create rules for both + // owners). + const transformedUser = getUserCredentials('case_2_a_transformed'); + const ruleOneTransformed = await createRule( + transformedUser, + 'case_2_a_transform_one', + 'case_2_feature_b', + 'alerting_rule_type_one' + ); + const ruleTwoTransformed = await createRule( + transformedUser, + 'case_2_a_transform_two', + 'case_2_feature_c', + 'alerting_rule_type_two' + ); + + // Users with deprecated privileges should be able to access rules created by themselves and + // users with new privileges. + for (const ruleToCheck of [ + ruleOneDeprecated, + ruleTwoDeprecated, + ruleOneTransformed, + ruleTwoTransformed, + ]) { + expect(await getRule(deprecatedUser, ruleToCheck.id)).toBeDefined(); + } + + // NOTE: Scenarios below require SO migrations for both alerting rules and alerts to switch to + // a new producer that is tied to feature ID. Presumably we won't have this requirement once + // https://github.com/elastic/kibana/pull/183756 is resolved. + + // Create rules as user with new privileges (B). + // const newUserB = getUserCredentials('case_2_b_new'); + // const caseOneNewB = await createRule(newUserB, { + // title: 'case_2_b_new_one', + // owner: 'cases_owner_one', + // }); + // + // // Create cases as user with new privileges (C). + // const newUserC = getUserCredentials('case_2_c_new'); + // const caseTwoNewC = await createRule(newUserC, { + // title: 'case_2_c_new_two', + // owner: 'cases_owner_two', + // }); + // + + // User B and User C should be able to access cases created by themselves and users with + // deprecated and transformed privileges, but only for the specific owner. + // for (const caseToCheck of [ruleOneDeprecated, ruleOneTransformed, caseOneNewB]) { + // expect(await getRule(newUserB, caseToCheck.id)).toBeDefined(); + // expect(await getRule(newUserC, caseToCheck.id)).toBeUndefined(); + // } + // for (const caseToCheck of [ruleTwoDeprecated, ruleTwoTransformed, caseTwoNewC]) { + // expect(await getRule(newUserC, caseToCheck.id)).toBeDefined(); + // expect(await getRule(newUserB, caseToCheck.id)).toBeUndefined(); + // } + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/features/index.ts b/x-pack/test/security_api_integration/tests/features/index.ts new file mode 100644 index 0000000000000..583ed77c02943 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/features/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('security APIs - Features', function () { + loadTestFile(require.resolve('./deprecated_features')); + }); +} diff --git a/yarn.lock b/yarn.lock index 591bcd87bb2c6..e5f95f99049a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5147,6 +5147,10 @@ version "0.0.0" uid "" +"@kbn/features-provider-plugin@link:x-pack/test/security_api_integration/plugins/features_provider": + version "0.0.0" + uid "" + "@kbn/fec-alerts-test-plugin@link:x-pack/test/functional_execution_context/plugins/alerts": version "0.0.0" uid "" @@ -6387,6 +6391,10 @@ version "0.0.0" uid "" +"@kbn/security-authorization-core-common@link:x-pack/packages/security/authorization_core_common": + version "0.0.0" + uid "" + "@kbn/security-authorization-core@link:x-pack/packages/security/authorization_core": version "0.0.0" uid ""