diff --git a/x-pack/plugins/features/common/feature.ts b/x-pack/plugins/features/common/feature.ts index 82fcc33f5c8c..ef32a8a80a0b 100644 --- a/x-pack/plugins/features/common/feature.ts +++ b/x-pack/plugins/features/common/feature.ts @@ -7,6 +7,7 @@ import { RecursiveReadonly } from '@kbn/utility-types'; import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; import { SubFeatureConfig, SubFeature } from './sub_feature'; +import { ReservedKibanaPrivilege } from './reserved_kibana_privilege'; /** * Interface for registering a feature. @@ -122,8 +123,8 @@ export interface FeatureConfig { * @private */ reserved?: { - privilege: FeatureKibanaPrivileges; description: string; + privileges: ReservedKibanaPrivilege[]; }; } diff --git a/x-pack/plugins/features/common/reserved_kibana_privilege.ts b/x-pack/plugins/features/common/reserved_kibana_privilege.ts new file mode 100644 index 000000000000..0186011382e8 --- /dev/null +++ b/x-pack/plugins/features/common/reserved_kibana_privilege.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FeatureKibanaPrivileges } from '.'; + +export interface ReservedKibanaPrivilege { + id: string; + privilege: FeatureKibanaPrivileges; +} diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index 5b4f7728c9f3..2039f8f6acda 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -110,19 +110,24 @@ describe('FeatureRegistry', () => { ], privilegesTooltip: 'some fancy tooltip', reserved: { - privilege: { - catalogue: ['foo'], - management: { - foo: ['bar'], + privileges: [ + { + id: 'reserved', + privilege: { + catalogue: ['foo'], + management: { + foo: ['bar'], + }, + app: ['app1'], + savedObject: { + all: ['space', 'etc', 'telemetry'], + read: ['canvas', 'config', 'url'], + }, + api: ['someApiEndpointTag', 'anotherEndpointTag'], + ui: ['allowsFoo', 'showBar', 'showBaz'], + }, }, - app: ['app1'], - savedObject: { - all: ['space', 'etc', 'telemetry'], - read: ['canvas', 'config', 'url'], - }, - api: ['someApiEndpointTag', 'anotherEndpointTag'], - ui: ['allowsFoo', 'showBar', 'showBaz'], - }, + ], description: 'some completely adequate description', }, }; @@ -264,13 +269,18 @@ describe('FeatureRegistry', () => { privileges: null, reserved: { description: 'foo', - privilege: { - ui: [], - savedObject: { - all: [], - read: [], + privileges: [ + { + id: 'reserved', + privilege: { + ui: [], + savedObject: { + all: [], + read: [], + }, + }, }, - }, + ], }, }; @@ -278,7 +288,7 @@ describe('FeatureRegistry', () => { featureRegistry.register(feature); const result = featureRegistry.getAll(); - const reservedPrivilege = result[0]!.reserved!.privilege; + const reservedPrivilege = result[0]!.reserved!.privileges[0].privilege; expect(reservedPrivilege.savedObject.all).toEqual(['telemetry']); expect(reservedPrivilege.savedObject.read).toEqual(['config', 'url']); }); @@ -520,14 +530,19 @@ describe('FeatureRegistry', () => { privileges: null, reserved: { description: 'something', - privilege: { - savedObject: { - all: [], - read: [], + privileges: [ + { + id: 'reserved', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar', 'baz'], + }, }, - ui: [], - app: ['foo', 'bar', 'baz'], - }, + ], }, }; @@ -546,14 +561,19 @@ describe('FeatureRegistry', () => { privileges: null, reserved: { description: 'something', - privilege: { - savedObject: { - all: [], - read: [], + privileges: [ + { + id: 'reserved', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar'], + }, }, - ui: [], - app: ['foo', 'bar'], - }, + ], }, }; @@ -666,15 +686,20 @@ describe('FeatureRegistry', () => { privileges: null, reserved: { description: 'something', - privilege: { - catalogue: ['foo', 'bar', 'baz'], - savedObject: { - all: [], - read: [], + privileges: [ + { + id: 'reserved', + privilege: { + catalogue: ['foo', 'bar', 'baz'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, - ui: [], - app: [], - }, + ], }, }; @@ -694,15 +719,20 @@ describe('FeatureRegistry', () => { privileges: null, reserved: { description: 'something', - privilege: { - catalogue: ['foo', 'bar'], - savedObject: { - all: [], - read: [], + privileges: [ + { + id: 'reserved', + privilege: { + catalogue: ['foo', 'bar'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, - ui: [], - app: [], - }, + ], }, }; @@ -840,18 +870,23 @@ describe('FeatureRegistry', () => { privileges: null, reserved: { description: 'something', - privilege: { - catalogue: ['bar'], - management: { - kibana: ['hey-there'], + privileges: [ + { + id: 'reserved', + privilege: { + catalogue: ['bar'], + management: { + kibana: ['hey-there'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, + ], }, }; @@ -874,18 +909,23 @@ describe('FeatureRegistry', () => { privileges: null, reserved: { description: 'something', - privilege: { - catalogue: ['bar'], - management: { - kibana: ['hey-there'], + privileges: [ + { + id: 'reserved', + privilege: { + catalogue: ['bar'], + management: { + kibana: ['hey-there'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, - savedObject: { - all: [], - read: [], - }, - ui: [], - app: [], - }, + ], }, }; @@ -896,6 +936,78 @@ describe('FeatureRegistry', () => { ); }); + it('allows multiple reserved feature privileges to be registered', () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + reserved: { + description: 'my reserved privileges', + privileges: [ + { + id: 'a_reserved_1', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + { + id: 'a_reserved_2', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], + }, + }; + + const featureRegistry = new FeatureRegistry(); + featureRegistry.register(feature); + const result = featureRegistry.getAll(); + expect(result).toHaveLength(1); + expect(result[0].reserved?.privileges).toHaveLength(2); + }); + + it('does not allow reserved privilege ids to start with "reserved_"', () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + reserved: { + description: 'my reserved privileges', + privileges: [ + { + id: 'reserved_1', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], + }, + }; + + const featureRegistry = new FeatureRegistry(); + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"child \\"reserved\\" fails because [child \\"privileges\\" fails because [\\"privileges\\" at position 0 fails because [child \\"id\\" fails because [\\"id\\" with value \\"reserved_1\\" fails to match the required pattern: /^(?!reserved_)[a-zA-Z0-9_-]+$/]]]]"` + ); + }); + it('cannot register feature after getAll has been called', () => { const feature1: FeatureConfig = { id: 'test-feature', diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts index 73a353cd2747..6140b7ac87ce 100644 --- a/x-pack/plugins/features/server/feature_registry.ts +++ b/x-pack/plugins/features/server/feature_registry.ts @@ -39,9 +39,9 @@ export class FeatureRegistry { function applyAutomaticPrivilegeGrants(feature: FeatureConfig): FeatureConfig { const allPrivilege = feature.privileges?.all; const readPrivilege = feature.privileges?.read; - const reservedPrivilege = feature.reserved?.privilege; + const reservedPrivileges = (feature.reserved?.privileges ?? []).map(rp => rp.privilege); - applyAutomaticAllPrivilegeGrants(allPrivilege, reservedPrivilege); + applyAutomaticAllPrivilegeGrants(allPrivilege, ...reservedPrivileges); applyAutomaticReadPrivilegeGrants(readPrivilege); return feature; diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index fdeceb30b4e3..403d9586bf16 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -18,6 +18,7 @@ const prohibitedFeatureIds: Array = ['catalogue', 'managem const featurePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/; const subFeaturePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/; const managementSectionIdRegex = /^[a-zA-Z0-9_-]+$/; +const reservedFeaturePrrivilegePartRegex = /^(?!reserved_)[a-zA-Z0-9_-]+$/; export const uiCapabilitiesRegex = /^[a-zA-Z0-9:_-]+$/; const managementSchema = Joi.object().pattern( @@ -118,8 +119,17 @@ const schema = Joi.object({ }), privilegesTooltip: Joi.string(), reserved: Joi.object({ - privilege: privilegeSchema.required(), description: Joi.string().required(), + privileges: Joi.array() + .items( + Joi.object({ + id: Joi.string() + .regex(reservedFeaturePrrivilegePartRegex) + .required(), + privilege: privilegeSchema.required(), + }) + ) + .required(), }), }); @@ -209,7 +219,9 @@ export function validateFeature(feature: FeatureConfig) { privilegeEntries.push(...Object.entries(feature.privileges)); } if (feature.reserved) { - privilegeEntries.push(['reserved', feature.reserved.privilege]); + feature.reserved.privileges.forEach(reservedPrivilege => { + privilegeEntries.push([reservedPrivilege.id, reservedPrivilege.privilege]); + }); } if (privilegeEntries.length === 0) { diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 7d3ef116e67a..c7add12be142 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -79,19 +79,36 @@ export class MlServerPlugin implements Plugin { - validateFeaturePrivileges(featuresService.getFeatures()); + const features = featuresService.getFeatures(); + validateFeaturePrivileges(features); + validateReservedPrivileges(features); await registerPrivilegesWithCluster(logger, privileges, applicationName, clusterClient); }, diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 3d25fc03f568..b023c12d35b7 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -409,13 +409,18 @@ describe('features', () => { }, privileges: null, reserved: { - privilege: { - savedObject: { - all: ['ignore-me-1', 'ignore-me-2'], - read: ['ignore-me-1', 'ignore-me-2'], + privileges: [ + { + id: 'reserved', + privilege: { + savedObject: { + all: ['ignore-me-1', 'ignore-me-2'], + read: ['ignore-me-1', 'ignore-me-2'], + }, + ui: ['ignore-me-1'], + }, }, - ui: ['ignore-me-1'], - }, + ], description: '', }, }), @@ -591,13 +596,18 @@ describe('reserved', () => { }, privileges: null, reserved: { - privilege: { - savedObject: { - all: [], - read: [], + privileges: [ + { + id: 'foo', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, }, - ui: [], - }, + ], description: '', }, }), @@ -627,13 +637,18 @@ describe('reserved', () => { app: [], privileges: null, reserved: { - privilege: { - savedObject: { - all: ['savedObject-all-1', 'savedObject-all-2'], - read: ['savedObject-read-1', 'savedObject-read-2'], + privileges: [ + { + id: 'foo', + privilege: { + savedObject: { + all: ['savedObject-all-1', 'savedObject-all-2'], + read: ['savedObject-read-1', 'savedObject-read-2'], + }, + ui: ['ui-1', 'ui-2'], + }, }, - ui: ['ui-1', 'ui-2'], - }, + ], description: '', }, }), diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index b25aad30a342..9a8935f80a17 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -110,10 +110,12 @@ export function privilegesFactory( }, reserved: features.reduce((acc: Record, feature: Feature) => { if (feature.reserved) { - acc[feature.id] = [ - actions.version, - ...featurePrivilegeBuilder.getActions(feature.reserved!.privilege, feature), - ]; + feature.reserved.privileges.forEach(reservedPrivilege => { + acc[reservedPrivilege.id] = [ + actions.version, + ...uniq(featurePrivilegeBuilder.getActions(reservedPrivilege.privilege, feature)), + ]; + }); } return acc; }, {}), diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts index ac386d287cff..cd2c7faa263c 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts @@ -26,13 +26,18 @@ it('allows features with reserved privileges to be defined', () => { privileges: null, reserved: { description: 'foo', - privilege: { - savedObject: { - all: ['foo'], - read: ['bar'], + privileges: [ + { + id: 'reserved', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, }, - ui: [], - }, + ], }, }); diff --git a/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts new file mode 100644 index 000000000000..26af0dadfb28 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/validate_reserved_privileges.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Feature } from '../../../features/server'; +import { validateReservedPrivileges } from './validate_reserved_privileges'; + +it('allows features to be defined without privileges', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + }); + + validateReservedPrivileges([feature]); +}); + +it('allows features with a single reserved privilege to be defined', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + reserved: { + description: 'foo', + privileges: [ + { + id: 'reserved', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, + }, + ], + }, + }); + + validateReservedPrivileges([feature]); +}); + +it('allows multiple features with reserved privileges to be defined', () => { + const feature1: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + reserved: { + description: 'foo', + privileges: [ + { + id: 'reserved-1', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, + }, + ], + }, + }); + + const feature2: Feature = new Feature({ + id: 'foo2', + name: 'foo', + app: [], + privileges: null, + reserved: { + description: 'foo', + privileges: [ + { + id: 'reserved-2', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, + }, + ], + }, + }); + + validateReservedPrivileges([feature1, feature2]); +}); + +it('prevents a feature from specifying the same reserved privilege id', () => { + const feature1: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + reserved: { + description: 'foo', + privileges: [ + { + id: 'reserved', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, + }, + { + id: 'reserved', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, + }, + ], + }, + }); + + expect(() => validateReservedPrivileges([feature1])).toThrowErrorMatchingInlineSnapshot( + `"Duplicate reserved privilege id detected: reserved. This is not allowed."` + ); +}); + +it('prevents features from sharing a reserved privilege id', () => { + const feature1: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + reserved: { + description: 'foo', + privileges: [ + { + id: 'reserved', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, + }, + ], + }, + }); + + const feature2: Feature = new Feature({ + id: 'foo2', + name: 'foo', + app: [], + privileges: null, + reserved: { + description: 'foo', + privileges: [ + { + id: 'reserved', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, + }, + ], + }, + }); + + expect(() => validateReservedPrivileges([feature1, feature2])).toThrowErrorMatchingInlineSnapshot( + `"Duplicate reserved privilege id detected: reserved. This is not allowed."` + ); +}); diff --git a/x-pack/plugins/security/server/authorization/validate_reserved_privileges.ts b/x-pack/plugins/security/server/authorization/validate_reserved_privileges.ts new file mode 100644 index 000000000000..0915308fc0f8 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/validate_reserved_privileges.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Feature } from '../../../features/server'; + +export function validateReservedPrivileges(features: Feature[]) { + const seenPrivilegeIds = new Set(); + + for (const feature of features) { + (feature?.reserved?.privileges ?? []).forEach(({ id }) => { + if (seenPrivilegeIds.has(id)) { + throw new Error(`Duplicate reserved privilege id detected: ${id}. This is not allowed.`); + } + seenPrivilegeIds.add(id); + }); + } +} diff --git a/x-pack/test/api_integration/apis/ml/index.ts b/x-pack/test/api_integration/apis/ml/index.ts index bcba156a64f0..4e21faa610bf 100644 --- a/x-pack/test/api_integration/apis/ml/index.ts +++ b/x-pack/test/api_integration/apis/ml/index.ts @@ -9,10 +9,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService, loadTestFile }: FtrProviderContext) { const ml = getService('ml'); - // ML tests need to be disabled in orde to get the ES snapshot with - // https://github.com/elastic/elasticsearch/pull/54713 promoted - // and should be re-enabled as part of https://github.com/elastic/kibana/pull/61980 - describe.skip('Machine Learning', function() { + describe('Machine Learning', function() { this.tags(['mlqa']); before(async () => { diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 77293ddff3f9..9bec3fd076e8 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -41,7 +41,7 @@ export default function({ getService }: FtrProviderContext) { }, global: ['all', 'read'], space: ['all', 'read'], - reserved: ['ml', 'monitoring'], + reserved: ['ml_user', 'ml_admin', 'monitoring'], }; await supertest 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 0b29fc1cac7d..1f9eac148b30 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -39,7 +39,7 @@ export default function({ getService }: FtrProviderContext) { }, global: ['all', 'read'], space: ['all', 'read'], - reserved: ['ml', 'monitoring'], + reserved: ['ml_user', 'ml_admin', 'monitoring'], }; await supertest diff --git a/x-pack/test/functional/apps/machine_learning/index.ts b/x-pack/test/functional/apps/machine_learning/index.ts index 143899a61ffb..47c699e30949 100644 --- a/x-pack/test/functional/apps/machine_learning/index.ts +++ b/x-pack/test/functional/apps/machine_learning/index.ts @@ -8,10 +8,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService, loadTestFile }: FtrProviderContext) { const ml = getService('ml'); - // ML tests need to be disabled in orde to get the ES snapshot with - // https://github.com/elastic/elasticsearch/pull/54713 promoted - // and should be re-enabled as part of https://github.com/elastic/kibana/pull/61980 - describe.skip('machine learning', function() { + describe('machine learning', function() { this.tags('ciGroup3'); before(async () => {