From 6fb6e66c8f5b0ebcc5e2855e37d39c44c153882a Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Fri, 29 May 2020 14:00:19 -0400 Subject: [PATCH] poc --- .../server/capabilities/merge_capabilities.ts | 7 +- x-pack/plugins/apm/server/plugin.ts | 2 +- x-pack/plugins/beats_management/kibana.json | 3 +- .../plugins/beats_management/server/plugin.ts | 31 ++ x-pack/plugins/canvas/server/plugin.ts | 2 +- .../cross_cluster_replication/kibana.json | 3 +- .../server/plugin.ts | 19 +- .../cross_cluster_replication/server/types.ts | 2 + x-pack/plugins/features/common/es_feature.ts | 97 ++++ .../feature_elasticsearch_privileges.ts | 41 ++ x-pack/plugins/features/common/index.ts | 2 + .../features/server/feature_registry.test.ts | 82 ++-- .../features/server/feature_registry.ts | 44 +- x-pack/plugins/features/server/index.ts | 9 +- x-pack/plugins/features/server/mocks.ts | 4 +- x-pack/plugins/features/server/plugin.test.ts | 4 +- x-pack/plugins/features/server/plugin.ts | 25 +- .../features/server/routes/index.test.ts | 8 +- .../plugins/features/server/routes/index.ts | 2 +- .../ui_capabilities_for_features.test.ts | 346 +++++++------- .../server/ui_capabilities_for_features.ts | 89 +++- x-pack/plugins/graph/server/plugin.ts | 2 +- .../index_lifecycle_management/kibana.json | 3 +- .../server/plugin.ts | 22 +- .../server/types.ts | 2 + x-pack/plugins/index_management/kibana.json | 3 +- .../plugins/index_management/server/plugin.ts | 19 +- .../plugins/index_management/server/types.ts | 2 + x-pack/plugins/infra/server/plugin.ts | 4 +- .../plugins/ingest_manager/server/plugin.ts | 2 +- x-pack/plugins/ingest_pipelines/kibana.json | 2 +- .../plugins/ingest_pipelines/server/plugin.ts | 25 +- .../plugins/ingest_pipelines/server/types.ts | 2 + x-pack/plugins/license_management/kibana.json | 2 +- .../license_management/server/plugin.ts | 19 +- .../license_management/server/types.ts | 2 + x-pack/plugins/logstash/kibana.json | 3 +- x-pack/plugins/logstash/server/plugin.ts | 31 ++ x-pack/plugins/maps/server/plugin.ts | 2 +- x-pack/plugins/ml/server/plugin.ts | 11 +- x-pack/plugins/monitoring/server/plugin.ts | 2 +- x-pack/plugins/remote_clusters/kibana.json | 3 +- .../plugins/remote_clusters/server/plugin.ts | 19 +- .../plugins/remote_clusters/server/types.ts | 2 + x-pack/plugins/rollup/kibana.json | 3 +- x-pack/plugins/rollup/server/plugin.ts | 19 +- x-pack/plugins/rollup/server/types.ts | 2 + .../public/management/management_service.ts | 7 +- x-pack/plugins/security/public/plugin.tsx | 2 +- .../authorization/api_authorization.test.ts | 8 +- .../server/authorization/api_authorization.ts | 2 +- .../authorization/app_authorization.test.ts | 4 +- .../server/authorization/app_authorization.ts | 2 +- .../authorization/authorization_service.ts | 1 + .../authorization/check_es_privileges.ts | 97 ++++ .../authorization/check_privileges.test.ts | 448 +++++++++++------- .../server/authorization/check_privileges.ts | 108 +++-- .../check_privileges_dynamically.test.ts | 10 +- .../check_privileges_dynamically.ts | 9 +- .../check_saved_objects_privileges.test.ts | 6 +- .../check_saved_objects_privileges.ts | 6 +- .../disable_ui_capabilities.test.ts | 136 +++++- .../authorization/disable_ui_capabilities.ts | 69 ++- .../server/authorization/index.mock.ts | 1 + .../security/server/authorization/types.ts | 16 + .../plugins/security/server/features/index.ts | 7 + .../server/features/security_features.ts | 94 ++++ x-pack/plugins/security/server/plugin.ts | 11 + ...ecure_saved_objects_client_wrapper.test.ts | 40 +- .../secure_saved_objects_client_wrapper.ts | 8 +- .../security_solution/server/plugin.ts | 2 +- x-pack/plugins/snapshot_restore/kibana.json | 3 +- .../plugins/snapshot_restore/server/plugin.ts | 21 +- .../plugins/snapshot_restore/server/types.ts | 2 + .../server/lib/spaces_client/spaces_client.ts | 18 +- x-pack/plugins/transform/kibana.json | 3 +- x-pack/plugins/transform/server/plugin.ts | 20 +- x-pack/plugins/transform/server/types.ts | 2 + x-pack/plugins/upgrade_assistant/kibana.json | 2 +- .../upgrade_assistant/server/plugin.ts | 21 +- x-pack/plugins/uptime/server/kibana.index.ts | 2 +- x-pack/plugins/watcher/kibana.json | 3 +- x-pack/plugins/watcher/server/plugin.ts | 29 +- x-pack/plugins/watcher/server/types.ts | 2 + .../actions_simulators/server/plugin.ts | 2 +- .../fixtures/plugins/alerts/server/plugin.ts | 2 +- .../plugins/foo_plugin/server/index.ts | 2 +- 87 files changed, 1704 insertions(+), 554 deletions(-) create mode 100644 x-pack/plugins/features/common/es_feature.ts create mode 100644 x-pack/plugins/features/common/feature_elasticsearch_privileges.ts create mode 100644 x-pack/plugins/security/server/authorization/check_es_privileges.ts create mode 100644 x-pack/plugins/security/server/features/index.ts create mode 100644 x-pack/plugins/security/server/features/security_features.ts diff --git a/src/core/server/capabilities/merge_capabilities.ts b/src/core/server/capabilities/merge_capabilities.ts index 95296346ad8353e..ea92cdb3aa07127 100644 --- a/src/core/server/capabilities/merge_capabilities.ts +++ b/src/core/server/capabilities/merge_capabilities.ts @@ -21,7 +21,7 @@ import { merge } from 'lodash'; import { Capabilities } from './types'; export const mergeCapabilities = (...sources: Array>): Capabilities => - merge({}, ...sources, (a: any, b: any) => { + merge({}, ...sources, (a: any, b: any, key: any) => { if ( (typeof a === 'boolean' && typeof b === 'object') || (typeof a === 'object' && typeof b === 'boolean') @@ -32,4 +32,9 @@ export const mergeCapabilities = (...sources: Array>): Cap if (typeof a === 'boolean' && typeof b === 'boolean' && a !== b) { throw new Error(`conflict trying to merge booleans with different values`); } + + // if (typeof a === 'object' && typeof b === 'object' && key === 'management') { + // console.log('INSIDE MANAGEMENT FOR MERGING'); + // return merge({}, a, b); + // } }); diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index eb781ee07830758..b9f4f77332390b0 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -129,7 +129,7 @@ export class APMPlugin implements Plugin { }; }); - plugins.features.registerFeature(APM_FEATURE); + plugins.features.registerKibanaFeature(APM_FEATURE); plugins.licensing.featureUsage.register( APM_SERVICE_MAPS_FEATURE_NAME, APM_SERVICE_MAPS_LICENSE_TYPE diff --git a/x-pack/plugins/beats_management/kibana.json b/x-pack/plugins/beats_management/kibana.json index 1b431216ef99235..33091e312e86cad 100644 --- a/x-pack/plugins/beats_management/kibana.json +++ b/x-pack/plugins/beats_management/kibana.json @@ -7,7 +7,8 @@ "requiredPlugins": [ "data", "licensing", - "management" + "management", + "features" ], "optionalPlugins": [ "security" diff --git a/x-pack/plugins/beats_management/server/plugin.ts b/x-pack/plugins/beats_management/server/plugin.ts index a82dbcb4a3a6ede..7bfe12ebc59b025 100644 --- a/x-pack/plugins/beats_management/server/plugin.ts +++ b/x-pack/plugins/beats_management/server/plugin.ts @@ -10,12 +10,14 @@ import { Plugin, PluginInitializerContext, } from '../../../../src/core/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; import { LicensingPluginStart } from '../../licensing/server'; import { BeatsManagementConfigType } from '../common'; interface SetupDeps { security?: SecurityPluginSetup; + features: FeaturesPluginSetup; } interface StartDeps { @@ -30,6 +32,35 @@ export class BeatsManagementPlugin implements Plugin<{}, {}, SetupDeps, StartDep public async setup(core: CoreSetup, plugins: SetupDeps) { this.initializerContext.config.create(); + plugins.features.registerElasticsearchFeature({ + id: 'beats_management', + management: { + ingest: ['beats_management'], + }, + privileges: { + all: { + ui: [], + management: { + ingest: ['beats_management'], + }, + requiredClusterPrivileges: [], + requiredIndexPrivileges: { + ['.management-beats']: ['all'], + }, + }, + read: { + ui: [], + management: { + ingest: ['beats_management'], + }, + requiredClusterPrivileges: [], + requiredIndexPrivileges: { + ['.management-beats']: ['all'], + }, + }, + }, + }); + return {}; } diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index 91a563473455972..93330c12d30f077 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -33,7 +33,7 @@ export class CanvasPlugin implements Plugin { coreSetup.savedObjects.registerType(customElementType); coreSetup.savedObjects.registerType(workpadType); - plugins.features.registerFeature({ + plugins.features.registerKibanaFeature({ id: 'canvas', name: 'Canvas', order: 400, diff --git a/x-pack/plugins/cross_cluster_replication/kibana.json b/x-pack/plugins/cross_cluster_replication/kibana.json index ccf98f41def4763..1b9c12e2f013504 100644 --- a/x-pack/plugins/cross_cluster_replication/kibana.json +++ b/x-pack/plugins/cross_cluster_replication/kibana.json @@ -8,7 +8,8 @@ "licensing", "management", "remoteClusters", - "indexManagement" + "indexManagement", + "features" ], "optionalPlugins": [ "usageCollection" diff --git a/x-pack/plugins/cross_cluster_replication/server/plugin.ts b/x-pack/plugins/cross_cluster_replication/server/plugin.ts index d9fe296553f38c5..6e5066497d49ee3 100644 --- a/x-pack/plugins/cross_cluster_replication/server/plugin.ts +++ b/x-pack/plugins/cross_cluster_replication/server/plugin.ts @@ -87,7 +87,7 @@ export class CrossClusterReplicationServerPlugin implements Plugin { this.ccrEsClient = this.ccrEsClient ?? (await getCustomEsClient(getStartServices)); return { diff --git a/x-pack/plugins/cross_cluster_replication/server/types.ts b/x-pack/plugins/cross_cluster_replication/server/types.ts index c287acf86eb2b65..62c96b48c43732b 100644 --- a/x-pack/plugins/cross_cluster_replication/server/types.ts +++ b/x-pack/plugins/cross_cluster_replication/server/types.ts @@ -5,6 +5,7 @@ */ import { IRouter } from 'src/core/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { IndexManagementPluginSetup } from '../../index_management/server'; import { RemoteClustersPluginSetup } from '../../remote_clusters/server'; @@ -16,6 +17,7 @@ export interface Dependencies { licensing: LicensingPluginSetup; indexManagement: IndexManagementPluginSetup; remoteClusters: RemoteClustersPluginSetup; + features: FeaturesPluginSetup; } export interface RouteDependencies { diff --git a/x-pack/plugins/features/common/es_feature.ts b/x-pack/plugins/features/common/es_feature.ts new file mode 100644 index 000000000000000..5f77ccdbcf314d5 --- /dev/null +++ b/x-pack/plugins/features/common/es_feature.ts @@ -0,0 +1,97 @@ +/* + * 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 { RecursiveReadonly } from '@kbn/utility-types'; +import { FeatureElasticsearchPrivileges } from './feature_elasticsearch_privileges'; + +/** + * Interface for registering an Elasticsearch feature. + * Feature registration allows plugins to hide their applications based + * on configured cluster privileges. + */ +export interface ElasticsearchFeatureConfig { + /** + * Unique identifier for this feature. + * This identifier is also used when generating UI Capabilities. + * + * @see UICapabilities + */ + id: string; + + /** + * Optional array of supported licenses. + * If omitted, all licenses are allowed. + * This does not restrict access to your feature based on license. + * Its only purpose is to inform the space and roles UIs on which features to display. + */ + validLicenses?: Array<'basic' | 'standard' | 'gold' | 'platinum' | 'enterprise' | 'trial'>; + + /** + * Management sections associated with this feature. + * + * @example + * ```ts + * // Enables access to the "Advanced Settings" management page within the Kibana section + * management: { + * kibana: ['settings'] + * } + * ``` + */ + management?: { + [sectionId: string]: string[]; + }; + + /** + * If this feature includes a catalogue entry, you can specify them here to control visibility based on the current space. + * + */ + catalogue?: string[]; + + /** + * Feature privilege definition. + * + * @example + * ```ts + * { + * all: {...}, + * read: {...} + * } + * ``` + * @see FeatureElasticsearchPrivileges + */ + privileges: { + all: FeatureElasticsearchPrivileges; + read: FeatureElasticsearchPrivileges; + }; +} + +export class ElasticsearchFeature { + constructor(protected readonly config: RecursiveReadonly) {} + + public get id() { + return this.config.id; + } + + public get catalogue() { + return this.config.catalogue; + } + + public get management() { + return this.config.management; + } + + public get validLicenses() { + return this.config.validLicenses; + } + + public get privileges() { + return this.config.privileges; + } + + public toRaw() { + return { ...this.config } as ElasticsearchFeatureConfig; + } +} diff --git a/x-pack/plugins/features/common/feature_elasticsearch_privileges.ts b/x-pack/plugins/features/common/feature_elasticsearch_privileges.ts new file mode 100644 index 000000000000000..ae11af05aa519aa --- /dev/null +++ b/x-pack/plugins/features/common/feature_elasticsearch_privileges.ts @@ -0,0 +1,41 @@ +/* + * 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. + */ + +/** + * Elasticsearch Feature privilege definition + */ +export interface FeatureElasticsearchPrivileges { + requiredClusterPrivileges: string[]; + requiredIndexPrivileges?: { + [indexName: string]: string[]; + }; + + /** + * A list of UI Capabilities that should be granted to users with this privilege. + * These capabilities will automatically be namespaces within your feature id. + * + * @example + * ```ts + * { + * ui: ['show', 'save'] + * } + * + * This translates in the UI to the following (assuming a feature id of "foo"): + * import { uiCapabilities } from 'ui/capabilities'; + * + * const canShowApp = uiCapabilities.foo.show; + * const canSave = uiCapabilities.foo.save; + * ``` + * Note: Since these are automatically namespaced, you are free to use generic names like "show" and "save". + * + * @see UICapabilities + */ + ui: string[]; + + management?: { + [sectionId: string]: string[]; + }; +} diff --git a/x-pack/plugins/features/common/index.ts b/x-pack/plugins/features/common/index.ts index e359efbda20d292..a8b00dd38570a0b 100644 --- a/x-pack/plugins/features/common/index.ts +++ b/x-pack/plugins/features/common/index.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +export { FeatureElasticsearchPrivileges } from './feature_elasticsearch_privileges'; export { FeatureKibanaPrivileges } from './feature_kibana_privileges'; +export { ElasticsearchFeature, ElasticsearchFeatureConfig } from './es_feature'; export { Feature, FeatureConfig } from './feature'; export { SubFeature, diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index 75022922917b326..10b2cf1046a08f4 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -17,8 +17,8 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); expect(result).toHaveLength(1); // Should be the equal, but not the same instance (i.e., a defensive copy) @@ -133,8 +133,8 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); expect(result).toHaveLength(1); // Should be the equal, but not the same instance (i.e., a defensive copy) @@ -150,7 +150,7 @@ describe('FeatureRegistry', () => { } as any; const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"child \\"privileges\\" fails because [\\"privileges\\" is required]"` ); }); @@ -186,7 +186,7 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"child \\"subFeatures\\" fails because [\\"subFeatures\\" must contain less than or equal to 0 items]"` ); }); @@ -215,8 +215,8 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); expect(result[0].privileges).toHaveProperty('all'); expect(result[0].privileges).toHaveProperty('read'); @@ -249,8 +249,8 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); expect(result[0].privileges).toHaveProperty('all'); expect(result[0].privileges).toHaveProperty('read'); @@ -285,8 +285,8 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); const reservedPrivilege = result[0]!.reserved!.privileges[0].privilege; expect(reservedPrivilege.savedObject.all).toEqual(['telemetry']); @@ -317,8 +317,8 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); expect(result[0].privileges).toHaveProperty('all'); expect(result[0].privileges).toHaveProperty('read'); @@ -346,18 +346,18 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); + featureRegistry.registerKibanaFeature(feature); - expect(() => featureRegistry.register(duplicateFeature)).toThrowErrorMatchingInlineSnapshot( - `"Feature with id test-feature is already registered."` - ); + expect(() => + featureRegistry.registerKibanaFeature(duplicateFeature) + ).toThrowErrorMatchingInlineSnapshot(`"Feature with id test-feature is already registered."`); }); ['contains space', 'contains_invalid()_chars', ''].forEach((prohibitedChars) => { it(`prevents features from being registered with a navLinkId of "${prohibitedChars}"`, () => { const featureRegistry = new FeatureRegistry(); expect(() => - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'foo', name: 'some feature', navLinkId: prohibitedChars, @@ -370,7 +370,7 @@ describe('FeatureRegistry', () => { it(`prevents features from being registered with a management id of "${prohibitedChars}"`, () => { const featureRegistry = new FeatureRegistry(); expect(() => - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'foo', name: 'some feature', management: { @@ -385,7 +385,7 @@ describe('FeatureRegistry', () => { it(`prevents features from being registered with a catalogue entry of "${prohibitedChars}"`, () => { const featureRegistry = new FeatureRegistry(); expect(() => - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'foo', name: 'some feature', catalogue: [prohibitedChars], @@ -400,7 +400,7 @@ describe('FeatureRegistry', () => { it(`prevents features from being registered with an ID of "${prohibitedId}"`, () => { const featureRegistry = new FeatureRegistry(); expect(() => - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: prohibitedId, name: 'some feature', app: [], @@ -430,7 +430,7 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"child \\"privileges\\" fails because [\\"foo\\" is not allowed]"` ); }); @@ -462,7 +462,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature privilege test-feature.all has unknown app entries: foo, baz"` ); }); @@ -517,7 +517,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` ); }); @@ -548,7 +548,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature privilege test-feature.reserved has unknown app entries: foo, baz"` ); }); @@ -579,7 +579,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` ); }); @@ -614,7 +614,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature privilege test-feature.all has unknown catalogue entries: foo, baz"` ); }); @@ -672,7 +672,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` ); }); @@ -705,7 +705,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature privilege test-feature.reserved has unknown catalogue entries: foo, baz"` ); }); @@ -738,7 +738,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` ); }); @@ -782,7 +782,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature privilege test-feature.all has unknown management section: elasticsearch"` ); }); @@ -853,7 +853,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature test-feature specifies management entries which are not granted to any privileges: elasticsearch.there"` ); }); @@ -892,7 +892,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature privilege test-feature.reserved has unknown management entries for section kibana: hey-there"` ); }); @@ -931,7 +931,7 @@ describe('FeatureRegistry', () => { const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(feature)).toThrowErrorMatchingInlineSnapshot( `"Feature test-feature specifies management entries which are not granted to any privileges: kibana.hey"` ); }); @@ -972,8 +972,8 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature); - const result = featureRegistry.getAll(); + featureRegistry.registerKibanaFeature(feature); + const result = featureRegistry.getAllKibanaFeatures(); expect(result).toHaveLength(1); expect(result[0].reserved?.privileges).toHaveLength(2); }); @@ -1003,7 +1003,7 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + expect(() => featureRegistry.registerKibanaFeature(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_-]+$/]]]]"` ); }); @@ -1023,10 +1023,10 @@ describe('FeatureRegistry', () => { }; const featureRegistry = new FeatureRegistry(); - featureRegistry.register(feature1); - featureRegistry.getAll(); + featureRegistry.registerKibanaFeature(feature1); + featureRegistry.getAllKibanaFeatures(); expect(() => { - featureRegistry.register(feature2); + featureRegistry.registerKibanaFeature(feature2); }).toThrowErrorMatchingInlineSnapshot( `"Features are locked, can't register new features. Attempt to register test-feature-2 failed."` ); diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts index 12aafd226f754c4..6220cd0ad8b43d4 100644 --- a/x-pack/plugins/features/server/feature_registry.ts +++ b/x-pack/plugins/features/server/feature_registry.ts @@ -5,14 +5,21 @@ */ import { cloneDeep, uniq } from 'lodash'; -import { FeatureConfig, Feature, FeatureKibanaPrivileges } from '../common'; +import { + FeatureConfig, + Feature, + FeatureKibanaPrivileges, + ElasticsearchFeatureConfig, + ElasticsearchFeature, +} from '../common'; import { validateFeature } from './feature_schema'; export class FeatureRegistry { private locked = false; - private features: Record = {}; + private kibanaFeatures: Record = {}; + private esFeatures: Record = {}; - public register(feature: FeatureConfig) { + public registerKibanaFeature(feature: FeatureConfig) { if (this.locked) { throw new Error( `Features are locked, can't register new features. Attempt to register ${feature.id} failed.` @@ -21,18 +28,41 @@ export class FeatureRegistry { validateFeature(feature); - if (feature.id in this.features) { + if (feature.id in this.kibanaFeatures || feature.id in this.esFeatures) { throw new Error(`Feature with id ${feature.id} is already registered.`); } const featureCopy = cloneDeep(feature); - this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy); + this.kibanaFeatures[feature.id] = applyAutomaticPrivilegeGrants(featureCopy); } - public getAll(): Feature[] { + public registerElasticsearchFeature(feature: ElasticsearchFeatureConfig) { + if (this.locked) { + throw new Error( + `Features are locked, can't register new features. Attempt to register ${feature.id} failed.` + ); + } + + if (feature.id in this.kibanaFeatures || feature.id in this.esFeatures) { + throw new Error(`Feature with id ${feature.id} is already registered.`); + } + + const featureCopy = cloneDeep(feature); + + this.esFeatures[feature.id] = featureCopy; + } + + public getAllKibanaFeatures(): Feature[] { + this.locked = true; + return Object.values(this.kibanaFeatures).map((featureConfig) => new Feature(featureConfig)); + } + + public getAllElasticsearchFeatures(): ElasticsearchFeature[] { this.locked = true; - return Object.values(this.features).map((featureConfig) => new Feature(featureConfig)); + return Object.values(this.esFeatures).map( + (featureConfig) => new ElasticsearchFeature(featureConfig) + ); } } diff --git a/x-pack/plugins/features/server/index.ts b/x-pack/plugins/features/server/index.ts index 48a350ae8f8fded..13d5f9047a322b7 100644 --- a/x-pack/plugins/features/server/index.ts +++ b/x-pack/plugins/features/server/index.ts @@ -13,7 +13,14 @@ import { Plugin } from './plugin'; // run-time contracts. export { uiCapabilitiesRegex } from './feature_schema'; -export { Feature, FeatureConfig, FeatureKibanaPrivileges } from '../common'; +export { + Feature, + FeatureConfig, + FeatureKibanaPrivileges, + ElasticsearchFeature, + ElasticsearchFeatureConfig, + FeatureElasticsearchPrivileges, +} from '../common'; export { PluginSetupContract, PluginStartContract } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/x-pack/plugins/features/server/mocks.ts b/x-pack/plugins/features/server/mocks.ts index d9437169a7453b9..36e0d7e04ed127b 100644 --- a/x-pack/plugins/features/server/mocks.ts +++ b/x-pack/plugins/features/server/mocks.ts @@ -9,8 +9,10 @@ import { PluginSetupContract, PluginStartContract } from './plugin'; const createSetup = (): jest.Mocked => { return { getFeatures: jest.fn(), + getElasticsearchFeatures: jest.fn(), getFeaturesUICapabilities: jest.fn(), - registerFeature: jest.fn(), + registerKibanaFeature: jest.fn(), + registerElasticsearchFeature: jest.fn(), }; }; diff --git a/x-pack/plugins/features/server/plugin.test.ts b/x-pack/plugins/features/server/plugin.test.ts index 79fd012337b00c1..20d1ec28bc3b97b 100644 --- a/x-pack/plugins/features/server/plugin.test.ts +++ b/x-pack/plugins/features/server/plugin.test.ts @@ -29,7 +29,7 @@ coreStart.savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); describe('Features Plugin', () => { it('returns OSS + registered features', async () => { const plugin = new Plugin(initContext); - const { registerFeature } = await plugin.setup(coreSetup, {}); + const { registerKibanaFeature: registerFeature } = await plugin.setup(coreSetup, {}); registerFeature({ id: 'baz', name: 'baz', @@ -55,7 +55,7 @@ describe('Features Plugin', () => { it('returns OSS + registered features with timelion when available', async () => { const plugin = new Plugin(initContext); - const { registerFeature } = await plugin.setup(coreSetup, { + const { registerKibanaFeature: registerFeature } = await plugin.setup(coreSetup, { visTypeTimelion: { uiEnabled: true }, }); registerFeature({ diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts index bfae416471c2f2c..cbed30f8b785a07 100644 --- a/x-pack/plugins/features/server/plugin.ts +++ b/x-pack/plugins/features/server/plugin.ts @@ -20,18 +20,21 @@ import { Feature, FeatureConfig } from '../common/feature'; import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features'; import { buildOSSFeatures } from './oss_features'; import { defineRoutes } from './routes'; +import { ElasticsearchFeatureConfig, ElasticsearchFeature } from '../common'; /** * Describes public Features plugin contract returned at the `setup` stage. */ export interface PluginSetupContract { - registerFeature(feature: FeatureConfig): void; + registerKibanaFeature(feature: FeatureConfig): void; + registerElasticsearchFeature(feature: ElasticsearchFeatureConfig): void; /* * Calling this function during setup will crash Kibana. * Use start contract instead. * @deprecated * */ getFeatures(): Feature[]; + getElasticsearchFeatures(): ElasticsearchFeature[]; getFeaturesUICapabilities(): UICapabilities; } @@ -63,9 +66,19 @@ export class Plugin { }); return deepFreeze({ - registerFeature: this.featureRegistry.register.bind(this.featureRegistry), - getFeatures: this.featureRegistry.getAll.bind(this.featureRegistry), - getFeaturesUICapabilities: () => uiCapabilitiesForFeatures(this.featureRegistry.getAll()), + registerKibanaFeature: this.featureRegistry.registerKibanaFeature.bind(this.featureRegistry), + registerElasticsearchFeature: this.featureRegistry.registerElasticsearchFeature.bind( + this.featureRegistry + ), + getFeatures: this.featureRegistry.getAllKibanaFeatures.bind(this.featureRegistry), + getElasticsearchFeatures: this.featureRegistry.getAllElasticsearchFeatures.bind( + this.featureRegistry + ), + getFeaturesUICapabilities: () => + uiCapabilitiesForFeatures( + this.featureRegistry.getAllKibanaFeatures(), + this.featureRegistry.getAllElasticsearchFeatures() + ), }); } @@ -73,7 +86,7 @@ export class Plugin { this.registerOssFeatures(core.savedObjects); return deepFreeze({ - getFeatures: this.featureRegistry.getAll.bind(this.featureRegistry), + getFeatures: this.featureRegistry.getAllKibanaFeatures.bind(this.featureRegistry), }); } @@ -97,7 +110,7 @@ export class Plugin { }); for (const feature of features) { - this.featureRegistry.register(feature); + this.featureRegistry.registerKibanaFeature(feature); } } } diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index c2e8cd6129d80f8..c8a609e8cdf9ba7 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -26,14 +26,14 @@ describe('GET /api/features', () => { let routeHandler: RequestHandler; beforeEach(() => { const featureRegistry = new FeatureRegistry(); - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'feature_1', name: 'Feature 1', app: [], privileges: null, }); - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'feature_2', name: 'Feature 2', order: 2, @@ -41,7 +41,7 @@ describe('GET /api/features', () => { privileges: null, }); - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'feature_3', name: 'Feature 2', order: 1, @@ -49,7 +49,7 @@ describe('GET /api/features', () => { privileges: null, }); - featureRegistry.register({ + featureRegistry.registerKibanaFeature({ id: 'licensed_feature', name: 'Licensed Feature', app: ['bar-app'], diff --git a/x-pack/plugins/features/server/routes/index.ts b/x-pack/plugins/features/server/routes/index.ts index 147d34d124fca0f..b5a4203d7a76856 100644 --- a/x-pack/plugins/features/server/routes/index.ts +++ b/x-pack/plugins/features/server/routes/index.ts @@ -26,7 +26,7 @@ export function defineRoutes({ router, featureRegistry }: RouteDefinitionParams) }, }, (context, request, response) => { - const allFeatures = featureRegistry.getAll(); + const allFeatures = featureRegistry.getAllKibanaFeatures(); return response.ok({ body: allFeatures diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts index 35dcc4cf42b3789..01dd353b396459c 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts @@ -35,44 +35,52 @@ function createSubFeaturePrivilege(privilegeId: string, capabilities: string[] = describe('populateUICapabilities', () => { it('handles no original uiCapabilities and no registered features gracefully', () => { - expect(uiCapabilitiesForFeatures([])).toEqual({}); + expect(uiCapabilitiesForFeatures([], [])).toEqual({}); }); it('handles features with no registered capabilities', () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(), - read: createFeaturePrivilege(), - }, - }), - ]) + uiCapabilitiesForFeatures( + [ + new Feature({ + id: 'newFeature', + name: 'my new feature', + app: ['bar-app'], + privileges: { + all: createFeaturePrivilege(), + read: createFeaturePrivilege(), + }, + }), + ], + [] + ) ).toEqual({ catalogue: {}, + management: {}, newFeature: {}, }); }); it('augments the original uiCapabilities with registered feature capabilities', () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(), - }, - }), - ]) + uiCapabilitiesForFeatures( + [ + new Feature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(), + }, + }), + ], + [] + ) ).toEqual({ catalogue: {}, + management: {}, newFeature: { capability1: true, capability2: true, @@ -82,24 +90,28 @@ describe('populateUICapabilities', () => { it('combines catalogue entries from multiple features', () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - catalogue: ['anotherFooEntry', 'anotherBarEntry'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['capability3', 'capability4']), - }, - }), - ]) + uiCapabilitiesForFeatures( + [ + new Feature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + catalogue: ['anotherFooEntry', 'anotherBarEntry'], + privileges: { + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), + }, + }), + ], + [] + ) ).toEqual({ catalogue: { anotherFooEntry: true, anotherBarEntry: true, }, + management: {}, newFeature: { capability1: true, capability2: true, @@ -111,20 +123,24 @@ describe('populateUICapabilities', () => { it(`merges capabilities from all feature privileges`, () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['capability3', 'capability4', 'capability5']), - }, - }), - ]) + uiCapabilitiesForFeatures( + [ + new Feature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4', 'capability5']), + }, + }), + ], + [] + ) ).toEqual({ catalogue: {}, + management: {}, newFeature: { capability1: true, capability2: true, @@ -137,30 +153,34 @@ describe('populateUICapabilities', () => { it(`supports capabilities from reserved privileges`, () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - privileges: null, - reserved: { - description: '', - privileges: [ - { - id: 'rp_1', - privilege: createFeaturePrivilege(['capability1', 'capability2']), - }, - { - id: 'rp_2', - privilege: createFeaturePrivilege(['capability3', 'capability4', 'capability5']), - }, - ], - }, - }), - ]) + uiCapabilitiesForFeatures( + [ + new Feature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: null, + reserved: { + description: '', + privileges: [ + { + id: 'rp_1', + privilege: createFeaturePrivilege(['capability1', 'capability2']), + }, + { + id: 'rp_2', + privilege: createFeaturePrivilege(['capability3', 'capability4', 'capability5']), + }, + ], + }, + }), + ], + [] + ) ).toEqual({ catalogue: {}, + management: {}, newFeature: { capability1: true, capability2: true, @@ -173,53 +193,57 @@ describe('populateUICapabilities', () => { it(`supports merging features with sub privileges`, () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['capability3', 'capability4']), - }, - subFeatures: [ - { - name: 'sub-feature-1', - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - createSubFeaturePrivilege('privilege-1', ['capability5']), - createSubFeaturePrivilege('privilege-2', ['capability6']), - ], - } as SubFeaturePrivilegeGroupConfig, - { - groupType: 'mutually_exclusive', - privileges: [ - createSubFeaturePrivilege('privilege-3', ['capability7']), - createSubFeaturePrivilege('privilege-4', ['capability8']), - ], - } as SubFeaturePrivilegeGroupConfig, - ], + uiCapabilitiesForFeatures( + [ + new Feature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - { - name: 'sub-feature-2', - privilegeGroups: [ - { - name: 'Group Name', - groupType: 'independent', - privileges: [ - createSubFeaturePrivilege('privilege-5', ['capability9', 'capability10']), - ], - } as SubFeaturePrivilegeGroupConfig, - ], - }, - ], - }), - ]) + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-1', ['capability5']), + createSubFeaturePrivilege('privilege-2', ['capability6']), + ], + } as SubFeaturePrivilegeGroupConfig, + { + groupType: 'mutually_exclusive', + privileges: [ + createSubFeaturePrivilege('privilege-3', ['capability7']), + createSubFeaturePrivilege('privilege-4', ['capability8']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + { + name: 'sub-feature-2', + privilegeGroups: [ + { + name: 'Group Name', + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-5', ['capability9', 'capability10']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + ], + }), + ], + [] + ) ).toEqual({ catalogue: {}, + management: {}, newFeature: { capability1: true, capability2: true, @@ -237,51 +261,54 @@ describe('populateUICapabilities', () => { it('supports merging multiple features with multiple privileges each', () => { expect( - uiCapabilitiesForFeatures([ - new Feature({ - id: 'newFeature', - name: 'my new feature', - navLinkId: 'newFeatureNavLink', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['capability3', 'capability4']), - }, - }), - new Feature({ - id: 'anotherNewFeature', - name: 'another new feature', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['capability3', 'capability4']), - }, - }), - new Feature({ - id: 'yetAnotherNewFeature', - name: 'yet another new feature', - navLinkId: 'yetAnotherNavLink', - app: ['bar-app'], - privileges: { - all: createFeaturePrivilege(['capability1', 'capability2']), - read: createFeaturePrivilege(['something1', 'something2', 'something3']), - }, - subFeatures: [ - { - name: 'sub-feature-1', - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - createSubFeaturePrivilege('privilege-1', ['capability3']), - createSubFeaturePrivilege('privilege-2', ['capability4']), - ], - } as SubFeaturePrivilegeGroupConfig, - ], + uiCapabilitiesForFeatures( + [ + new Feature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), + }, + }), + new Feature({ + id: 'anotherNewFeature', + name: 'another new feature', + app: ['bar-app'], + privileges: { + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), + }, + }), + new Feature({ + id: 'yetAnotherNewFeature', + name: 'yet another new feature', + navLinkId: 'yetAnotherNavLink', + app: ['bar-app'], + privileges: { + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['something1', 'something2', 'something3']), }, - ], - }), - ]) + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-1', ['capability3']), + createSubFeaturePrivilege('privilege-2', ['capability4']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + ], + }), + ], + [] + ) ).toEqual({ anotherNewFeature: { capability1: true, @@ -290,6 +317,7 @@ describe('populateUICapabilities', () => { capability4: true, }, catalogue: {}, + management: {}, newFeature: { capability1: true, capability2: true, diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.ts index e41035e9365cec2..612b97e432849f1 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.ts @@ -7,17 +7,23 @@ import _ from 'lodash'; import { Capabilities as UICapabilities } from '../../../../src/core/server'; import { Feature } from '../common/feature'; +import { ElasticsearchFeature } from '../common'; const ELIGIBLE_FLAT_MERGE_KEYS = ['catalogue']; +const ELIGIBLE_DEEP_MERGE_KEYS = ['management']; interface FeatureCapabilities { [featureId: string]: Record; } -export function uiCapabilitiesForFeatures(features: Feature[]): UICapabilities { - const featureCapabilities: FeatureCapabilities[] = features.map(getCapabilitiesFromFeature); +export function uiCapabilitiesForFeatures( + features: Feature[], + elasticsearchFeatures: ElasticsearchFeature[] +): UICapabilities { + const featureCapabilities = features.map(getCapabilitiesFromFeature); + const esFeatureCapabilities = elasticsearchFeatures.map(getCapabilitiesFromElasticsearchFeature); - return buildCapabilities(...featureCapabilities); + return buildCapabilities(...featureCapabilities, ...esFeatureCapabilities); } function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities { @@ -39,6 +45,21 @@ function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities { }; } + if (feature.management) { + const sectionEntries = Object.entries(feature.management); + UIFeatureCapabilities.management = sectionEntries.reduce((acc, [sectionId, sectionItems]) => { + return { + ...acc, + [sectionId]: sectionItems.reduce((acc2, item) => { + return { + ...acc2, + [item]: true, + }; + }, {}), + }; + }, {}); + } + const featurePrivileges = Object.values(feature.privileges ?? {}); if (feature.subFeatures) { featurePrivileges.push( @@ -65,6 +86,60 @@ function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities { return UIFeatureCapabilities; } +function getCapabilitiesFromElasticsearchFeature( + feature: ElasticsearchFeature +): FeatureCapabilities { + const UIFeatureCapabilities: FeatureCapabilities = { + catalogue: {}, + [feature.id]: {}, + }; + + if (feature.catalogue) { + UIFeatureCapabilities.catalogue = { + ...UIFeatureCapabilities.catalogue, + ...feature.catalogue.reduce( + (acc, capability) => ({ + ...acc, + [capability]: true, + }), + {} + ), + }; + } + + if (feature.management) { + const sectionEntries = Object.entries(feature.management); + UIFeatureCapabilities.management = sectionEntries.reduce((acc, [sectionId, sectionItems]) => { + return { + ...acc, + [sectionId]: sectionItems.reduce((acc2, item) => { + return { + ...acc2, + [item]: true, + }; + }, {}), + }; + }, {}); + } + + const featurePrivileges = Object.values(feature.privileges ?? {}); + + featurePrivileges.forEach((privilege) => { + UIFeatureCapabilities[feature.id] = { + ...UIFeatureCapabilities[feature.id], + ...privilege.ui.reduce( + (privilegeAcc, capability) => ({ + ...privilegeAcc, + [capability]: true, + }), + {} + ), + }; + }); + + return UIFeatureCapabilities; +} + function buildCapabilities(...allFeatureCapabilities: FeatureCapabilities[]): UICapabilities { return allFeatureCapabilities.reduce((acc, capabilities) => { const mergableCapabilities: UICapabilities = _.omit(capabilities, ...ELIGIBLE_FLAT_MERGE_KEYS); @@ -81,6 +156,14 @@ function buildCapabilities(...allFeatureCapabilities: FeatureCapabilities[]): UI }; }); + ELIGIBLE_DEEP_MERGE_KEYS.forEach((key) => { + mergedFeatureCapabilities[key] = _.merge( + {}, + mergedFeatureCapabilities[key], + capabilities[key] + ); + }); + return mergedFeatureCapabilities; }, {} as UICapabilities); } diff --git a/x-pack/plugins/graph/server/plugin.ts b/x-pack/plugins/graph/server/plugin.ts index 141d5d0ea8db4c1..22a25ea353ec7f9 100644 --- a/x-pack/plugins/graph/server/plugin.ts +++ b/x-pack/plugins/graph/server/plugin.ts @@ -40,7 +40,7 @@ export class GraphPlugin implements Plugin { } if (features) { - features.registerFeature({ + features.registerKibanaFeature({ id: 'graph', name: i18n.translate('xpack.graph.featureRegistry.graphFeatureName', { defaultMessage: 'Graph', diff --git a/x-pack/plugins/index_lifecycle_management/kibana.json b/x-pack/plugins/index_lifecycle_management/kibana.json index 6385646b9578909..2b9e2264d4bee34 100644 --- a/x-pack/plugins/index_lifecycle_management/kibana.json +++ b/x-pack/plugins/index_lifecycle_management/kibana.json @@ -6,7 +6,8 @@ "requiredPlugins": [ "home", "licensing", - "management" + "management", + "features" ], "optionalPlugins": [ "usageCollection", diff --git a/x-pack/plugins/index_lifecycle_management/server/plugin.ts b/x-pack/plugins/index_lifecycle_management/server/plugin.ts index 79a9849e634b5f1..1001126f31bc096 100644 --- a/x-pack/plugins/index_lifecycle_management/server/plugin.ts +++ b/x-pack/plugins/index_lifecycle_management/server/plugin.ts @@ -47,7 +47,10 @@ export class IndexLifecycleManagementServerPlugin implements Plugin { + async setup( + { http }: CoreSetup, + { licensing, indexManagement, features }: Dependencies + ): Promise { const router = http.createRouter(); const config = await this.config$.pipe(first()).toPromise(); @@ -65,6 +68,23 @@ export class IndexLifecycleManagementServerPlugin implements Plugin { this.dataManagementESClient = this.dataManagementESClient ?? (await getCustomEsClient(getStartServices)); diff --git a/x-pack/plugins/index_management/server/types.ts b/x-pack/plugins/index_management/server/types.ts index dc151f510a04379..fccbe05695dd928 100644 --- a/x-pack/plugins/index_management/server/types.ts +++ b/x-pack/plugins/index_management/server/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { ScopedClusterClient, IRouter } from 'src/core/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; import { License, IndexDataEnricher } from './services'; @@ -12,6 +13,7 @@ import { isEsError } from './shared_imports'; export interface Dependencies { security: SecurityPluginSetup; licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; } export interface RouteDependencies { diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 2fd614830c05df4..f22438b6638e4b9 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -137,8 +137,8 @@ export class InfraServerPlugin { ...domainLibs, }; - plugins.features.registerFeature(METRICS_FEATURE); - plugins.features.registerFeature(LOGS_FEATURE); + plugins.features.registerKibanaFeature(METRICS_FEATURE); + plugins.features.registerKibanaFeature(LOGS_FEATURE); plugins.home.sampleData.addAppLinksToSampleDataset('logs', [ { diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index fb1c218e1545b47..87f23ad4d8519d1 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -141,7 +141,7 @@ export class IngestManagerPlugin // Register feature // TODO: Flesh out privileges if (deps.features) { - deps.features.registerFeature({ + deps.features.registerKibanaFeature({ id: PLUGIN_ID, name: 'Ingest Manager', icon: 'savedObjectsApp', diff --git a/x-pack/plugins/ingest_pipelines/kibana.json b/x-pack/plugins/ingest_pipelines/kibana.json index cb24133b1f6ba4a..c13613af563942c 100644 --- a/x-pack/plugins/ingest_pipelines/kibana.json +++ b/x-pack/plugins/ingest_pipelines/kibana.json @@ -3,7 +3,7 @@ "version": "8.0.0", "server": true, "ui": true, - "requiredPlugins": ["licensing", "management"], + "requiredPlugins": ["licensing", "management", "features"], "optionalPlugins": ["security", "usageCollection"], "configPath": ["xpack", "ingest_pipelines"] } diff --git a/x-pack/plugins/ingest_pipelines/server/plugin.ts b/x-pack/plugins/ingest_pipelines/server/plugin.ts index 7a78bf608b8e1a7..f379d21c38b896e 100644 --- a/x-pack/plugins/ingest_pipelines/server/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/server/plugin.ts @@ -25,7 +25,7 @@ export class IngestPipelinesPlugin implements Plugin { this.apiRoutes = new ApiRoutes(); } - public setup({ http }: CoreSetup, { licensing, security }: Dependencies) { + public setup({ http }: CoreSetup, { licensing, security, features }: Dependencies) { this.logger.debug('ingest_pipelines: setup'); const router = http.createRouter(); @@ -44,6 +44,29 @@ export class IngestPipelinesPlugin implements Plugin { } ); + features.registerElasticsearchFeature({ + id: 'ingest_pipelines', + management: { + ingest: ['ingest_pipelines'], + }, + privileges: { + all: { + ui: [], + management: { + ingest: ['ingest_pipelines'], + }, + requiredClusterPrivileges: ['manage_pipeline', 'cluster:monitor/nodes/info'], + }, + read: { + ui: [], + management: { + ingest: ['ingest_pipelines'], + }, + requiredClusterPrivileges: ['manage_pipeline', 'cluster:monitor/nodes/info'], + }, + }, + }); + this.apiRoutes.setup({ router, license: this.license, diff --git a/x-pack/plugins/ingest_pipelines/server/types.ts b/x-pack/plugins/ingest_pipelines/server/types.ts index 261317daa26d982..c5d9158caa569d9 100644 --- a/x-pack/plugins/ingest_pipelines/server/types.ts +++ b/x-pack/plugins/ingest_pipelines/server/types.ts @@ -7,11 +7,13 @@ import { IRouter } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { License } from './services'; import { isEsError } from './shared_imports'; export interface Dependencies { security: SecurityPluginSetup; + features: FeaturesPluginSetup; licensing: LicensingPluginSetup; } diff --git a/x-pack/plugins/license_management/kibana.json b/x-pack/plugins/license_management/kibana.json index 6da923c5cff5a31..4b0fdb7ae390f8d 100644 --- a/x-pack/plugins/license_management/kibana.json +++ b/x-pack/plugins/license_management/kibana.json @@ -3,7 +3,7 @@ "version": "kibana", "server": true, "ui": true, - "requiredPlugins": ["home", "licensing", "management"], + "requiredPlugins": ["home", "licensing", "management", "features"], "optionalPlugins": ["telemetry"], "configPath": ["xpack", "license_management"], "extraPublicDirs": ["common/constants"] diff --git a/x-pack/plugins/license_management/server/plugin.ts b/x-pack/plugins/license_management/server/plugin.ts index 7b1887e43802450..e4a58ec4e9d816f 100644 --- a/x-pack/plugins/license_management/server/plugin.ts +++ b/x-pack/plugins/license_management/server/plugin.ts @@ -13,9 +13,26 @@ import { Dependencies } from './types'; export class LicenseManagementServerPlugin implements Plugin { private readonly apiRoutes = new ApiRoutes(); - setup({ http }: CoreSetup, { licensing, security }: Dependencies) { + setup({ http }: CoreSetup, { licensing, features, security }: Dependencies) { const router = http.createRouter(); + features.registerElasticsearchFeature({ + id: 'license_management', + management: { + stack: ['license_management'], + }, + privileges: { + all: { + requiredClusterPrivileges: ['manage'], + ui: [], + }, + read: { + requiredClusterPrivileges: ['manage'], + ui: [], + }, + }, + }); + this.apiRoutes.setup({ router, plugins: { diff --git a/x-pack/plugins/license_management/server/types.ts b/x-pack/plugins/license_management/server/types.ts index 11b1ed696874bc3..767f2933a9cd942 100644 --- a/x-pack/plugins/license_management/server/types.ts +++ b/x-pack/plugins/license_management/server/types.ts @@ -5,12 +5,14 @@ */ import { ScopedClusterClient, IRouter } from 'kibana/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; import { isEsError } from './shared_imports'; export interface Dependencies { licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; security?: SecurityPluginSetup; } diff --git a/x-pack/plugins/logstash/kibana.json b/x-pack/plugins/logstash/kibana.json index 1eb325dcc1610b9..9c33736d40443a0 100644 --- a/x-pack/plugins/logstash/kibana.json +++ b/x-pack/plugins/logstash/kibana.json @@ -5,7 +5,8 @@ "configPath": ["xpack", "logstash"], "requiredPlugins": [ "licensing", - "management" + "management", + "features" ], "optionalPlugins": [ "home", diff --git a/x-pack/plugins/logstash/server/plugin.ts b/x-pack/plugins/logstash/server/plugin.ts index c048dd13bfb0cfe..872d043f0e5e17a 100644 --- a/x-pack/plugins/logstash/server/plugin.ts +++ b/x-pack/plugins/logstash/server/plugin.ts @@ -12,6 +12,7 @@ import { PluginInitializerContext, } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; import { registerRoutes } from './routes'; @@ -19,6 +20,7 @@ import { registerRoutes } from './routes'; interface SetupDeps { licensing: LicensingPluginSetup; security?: SecurityPluginSetup; + features: FeaturesPluginSetup; } export class LogstashPlugin implements Plugin { @@ -34,6 +36,35 @@ export class LogstashPlugin implements Plugin { this.coreSetup = core; registerRoutes(core.http.createRouter(), deps.security); + + deps.features.registerElasticsearchFeature({ + id: 'pipelines', + management: { + ingest: ['pipelines'], + }, + privileges: { + all: { + requiredClusterPrivileges: [], + requiredIndexPrivileges: { + ['.logstash']: ['read'], + }, + management: { + ingest: ['pipelines'], + }, + ui: [], + }, + read: { + requiredClusterPrivileges: [], + requiredIndexPrivileges: { + ['.logstash']: ['read'], + }, + management: { + ingest: ['pipelines'], + }, + ui: [], + }, + }, + }); } start(core: CoreStart) { diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index f2331b9a1a96001..36fbb639a093ad6 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -159,7 +159,7 @@ export class MapsPlugin implements Plugin { this._initHomeData(home, core.http.basePath.prepend, currentConfig); - features.registerFeature({ + features.registerKibanaFeature({ id: APP_ID, name: i18n.translate('xpack.maps.featureRegistry.mapsFeatureName', { defaultMessage: 'Maps', diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index b167214cc33cfdf..5c8e4049aa6e71f 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -77,7 +77,7 @@ export class MlServerPlugin implements Plugin { public setup( { http, uiSettings, getStartServices }: CoreSetup, - { licensing, indexManagement, visTypeTimeseries, usageCollection }: Dependencies + { features, licensing, indexManagement, visTypeTimeseries, usageCollection }: Dependencies ) { this.license.setup( { @@ -80,6 +80,23 @@ export class RollupPlugin implements Plugin { } ); + features.registerElasticsearchFeature({ + id: 'rollup_jobs', + management: { + data: ['rollup_jobs'], + }, + privileges: { + all: { + requiredClusterPrivileges: ['manage_rollup'], + ui: [], + }, + read: { + requiredClusterPrivileges: ['manage_rollup'], + ui: [], + }, + }, + }); + http.registerRouteHandlerContext('rollup', async (context, request) => { this.rollupEsClient = this.rollupEsClient ?? (await getCustomEsClient(getStartServices)); return { diff --git a/x-pack/plugins/rollup/server/types.ts b/x-pack/plugins/rollup/server/types.ts index 6659ac2b0235799..64754627d7c0b82 100644 --- a/x-pack/plugins/rollup/server/types.ts +++ b/x-pack/plugins/rollup/server/types.ts @@ -9,6 +9,7 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server'; import { IndexManagementPluginSetup } from '../../index_management/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { License } from './services'; import { IndexPatternsFetcher } from './shared_imports'; @@ -22,6 +23,7 @@ export interface Dependencies { visTypeTimeseries?: VisTypeTimeseriesSetup; usageCollection?: UsageCollectionSetup; licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; } export interface RouteDependencies { diff --git a/x-pack/plugins/security/public/management/management_service.ts b/x-pack/plugins/security/public/management/management_service.ts index 148d2855ba9b743..c996ef17e5700f7 100644 --- a/x-pack/plugins/security/public/management/management_service.ts +++ b/x-pack/plugins/security/public/management/management_service.ts @@ -5,7 +5,7 @@ */ import { Subscription } from 'rxjs'; -import { StartServicesAccessor, FatalErrorsSetup } from 'src/core/public'; +import { StartServicesAccessor, FatalErrorsSetup, Capabilities } from 'src/core/public'; import { ManagementApp, ManagementSetup, @@ -30,6 +30,7 @@ interface SetupParams { interface StartParams { management: ManagementStart; + capabilities: Capabilities; } export class ManagementService { @@ -49,7 +50,7 @@ export class ManagementService { securitySection.registerApp(roleMappingsManagementApp.create({ getStartServices })); } - start({ management }: StartParams) { + start({ management, capabilities }: StartParams) { this.licenseFeaturesSubscription = this.license.features$.subscribe(async (features) => { const securitySection = management.sections.getSection(ManagementSectionId.Security); @@ -70,7 +71,7 @@ export class ManagementService { continue; } - if (enableStatus) { + if (enableStatus && capabilities.management.security[app.id] === true) { app.enable(); } else { app.disable(); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index da69dd051c11d3f..4e42fcc2180eb6f 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -141,7 +141,7 @@ export class SecurityPlugin this.navControlService.start({ core }); if (management) { - this.managementService.start({ management }); + this.managementService.start({ management, capabilities: core.application.capabilities }); } } diff --git a/x-pack/plugins/security/server/authorization/api_authorization.test.ts b/x-pack/plugins/security/server/authorization/api_authorization.test.ts index 75aa27c3c88c6a9..d4ec9a0e0db51a4 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.test.ts @@ -94,7 +94,9 @@ describe('initAPIAuthorization', () => { expect(mockResponse.notFound).not.toHaveBeenCalled(); expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockAuthz.actions.api.get('foo')]); + expect(mockCheckPrivileges).toHaveBeenCalledWith({ + kibana: [mockAuthz.actions.api.get('foo')], + }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); @@ -129,7 +131,9 @@ describe('initAPIAuthorization', () => { expect(mockResponse.notFound).toHaveBeenCalledTimes(1); expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockAuthz.actions.api.get('foo')]); + expect(mockCheckPrivileges).toHaveBeenCalledWith({ + kibana: [mockAuthz.actions.api.get('foo')], + }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); }); diff --git a/x-pack/plugins/security/server/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts index 0ffd3ba7ba8239a..9129330ec947ae0 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.ts @@ -29,7 +29,7 @@ export function initAPIAuthorization( const apiActions = actionTags.map((tag) => actions.api.get(tag.substring(tagPrefix.length))); const checkPrivileges = checkPrivilegesDynamicallyWithRequest(request); - const checkPrivilegesResponse = await checkPrivileges(apiActions); + const checkPrivilegesResponse = await checkPrivileges({ kibana: apiActions }); // we've actually authorized the request if (checkPrivilegesResponse.hasAllRequested) { diff --git a/x-pack/plugins/security/server/authorization/app_authorization.test.ts b/x-pack/plugins/security/server/authorization/app_authorization.test.ts index 2d3a981fb324723..cbe0b2a736c4448 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.test.ts @@ -129,7 +129,7 @@ describe('initAppAuthorization', () => { expect(mockResponse.notFound).not.toHaveBeenCalled(); expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); - expect(mockCheckPrivileges).toHaveBeenCalledWith(mockAuthz.actions.app.get('foo')); + expect(mockCheckPrivileges).toHaveBeenCalledWith({ kibana: mockAuthz.actions.app.get('foo') }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); @@ -169,7 +169,7 @@ describe('initAppAuthorization', () => { expect(mockResponse.notFound).toHaveBeenCalledTimes(1); expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); - expect(mockCheckPrivileges).toHaveBeenCalledWith(mockAuthz.actions.app.get('foo')); + expect(mockCheckPrivileges).toHaveBeenCalledWith({ kibana: mockAuthz.actions.app.get('foo') }); expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); }); diff --git a/x-pack/plugins/security/server/authorization/app_authorization.ts b/x-pack/plugins/security/server/authorization/app_authorization.ts index 1036997ca821d10..b7f3360e3c35d3e 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.ts @@ -63,7 +63,7 @@ export function initAppAuthorization( const checkPrivileges = checkPrivilegesDynamicallyWithRequest(request); const appAction = actions.app.get(appId); - const checkPrivilegesResponse = await checkPrivileges(appAction); + const checkPrivilegesResponse = await checkPrivileges({ kibana: appAction }); logger.debug(`authorizing access to "${appId}"`); // we've actually authorized the request diff --git a/x-pack/plugins/security/server/authorization/authorization_service.ts b/x-pack/plugins/security/server/authorization/authorization_service.ts index 989784a1436d261..39af99063cfc7b6 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.ts @@ -140,6 +140,7 @@ export class AuthorizationService { const disableUICapabilities = disableUICapabilitiesFactory( request, features.getFeatures(), + features.getElasticsearchFeatures(), this.logger, authz ); diff --git a/x-pack/plugins/security/server/authorization/check_es_privileges.ts b/x-pack/plugins/security/server/authorization/check_es_privileges.ts new file mode 100644 index 000000000000000..135ecafeec19a29 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/check_es_privileges.ts @@ -0,0 +1,97 @@ +/* + * 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 { IClusterClient, KibanaRequest } from '../../../../../src/core/server'; +import { HasPrivilegesResponse } from './types'; + +export interface CheckElasticsearchPrivilegesResponse { + hasAllRequested: boolean; + username: string; + privileges: { + cluster: Array<{ + privilege: string; + authorized: boolean; + }>; + index: { + [indexName: string]: Array<{ + privilege: string; + authorized: boolean; + }>; + }; + }; +} + +export type CheckElasticsearchPrivilegesWithRequest = ( + request: KibanaRequest +) => CheckElasticsearchPrivileges; + +export type CheckElasticsearchPrivileges = (privileges: { + cluster: string[]; + index: Record; +}) => Promise; + +export function checkElasticsearchPrivilegesWithRequestFactory(clusterClient: IClusterClient) { + return function checkElasticsearchPrivilegesWithRequest( + request: KibanaRequest + ): CheckElasticsearchPrivileges { + const checkElasticsearchPrivileges = async (privileges: { + cluster: string[]; + index: Record; + }): Promise => { + const hasPrivilegesResponse = (await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.hasPrivileges', { + body: { + cluster: privileges.cluster, + index: Object.entries(privileges.index).map(([names, indexPrivileges]) => ({ + names, + privileges: indexPrivileges, + })), + }, + })) as HasPrivilegesResponse; + + // validateEsPrivilegeResponse( + // hasPrivilegesResponse, + // applicationName, + // allApplicationPrivileges, + // resources + // ); + + const clusterPrivilegesResponse = hasPrivilegesResponse.cluster!; + + const clusterPrivileges = Object.entries(clusterPrivilegesResponse).map( + ([privilege, authorized]) => ({ + privilege, + authorized, + }) + ); + + const indexPrivileges = Object.entries(hasPrivilegesResponse.index!).reduce( + (acc, [index, indexResponse]) => { + return { + ...acc, + [index]: Object.entries(indexResponse).map(([privilege, authorized]) => ({ + privilege, + authorized, + })), + }; + }, + {} as CheckElasticsearchPrivilegesResponse['privileges']['index'] + ); + + return { + hasAllRequested: hasPrivilegesResponse.has_all_requested, + username: hasPrivilegesResponse.username, + privileges: { + cluster: clusterPrivileges, + index: indexPrivileges, + }, + }; + }; + + return checkElasticsearchPrivileges; + }; +} diff --git a/x-pack/plugins/security/server/authorization/check_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_privileges.test.ts index 65a3d1bf1650b80..25d90f751810b4e 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -50,7 +50,9 @@ describe('#atSpace', () => { let actualResult; let errorThrown = null; try { - actualResult = await checkPrivileges.atSpace(options.spaceId, options.privilegeOrPrivileges); + actualResult = await checkPrivileges.atSpace(options.spaceId, { + kibana: options.privilegeOrPrivileges, + }); } catch (err) { errorThrown = err; } @@ -58,6 +60,7 @@ describe('#atSpace', () => { expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { body: { + index: [], applications: [ { application, @@ -100,13 +103,19 @@ describe('#atSpace', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "mock-action:login", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", + }, + ], + }, "username": "foo-username", } `); @@ -132,13 +141,19 @@ describe('#atSpace', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": false, - "privilege": "mock-action:login", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": "space_1", + }, + ], + }, "username": "foo-username", } `); @@ -191,18 +206,24 @@ describe('#atSpace', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + ], + }, "username": "foo-username", } `); @@ -233,18 +254,24 @@ describe('#atSpace', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": false, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + ], + }, "username": "foo-username", } `); @@ -319,10 +346,9 @@ describe('#atSpaces', () => { let actualResult; let errorThrown = null; try { - actualResult = await checkPrivileges.atSpaces( - options.spaceIds, - options.privilegeOrPrivileges - ); + actualResult = await checkPrivileges.atSpaces(options.spaceIds, { + kibana: options.privilegeOrPrivileges, + }); } catch (err) { errorThrown = err; } @@ -330,6 +356,7 @@ describe('#atSpaces', () => { expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { body: { + index: [], applications: [ { application, @@ -376,18 +403,24 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "mock-action:login", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - Object { - "authorized": true, - "privilege": "mock-action:login", - "resource": "space_2", - }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -417,18 +450,24 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "mock-action:login", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - Object { - "authorized": false, - "privilege": "mock-action:login", - "resource": "space_2", - }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -520,28 +559,34 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_2", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_2", - }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -578,28 +623,34 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", - }, - Object { - "authorized": false, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", - }, - Object { - "authorized": false, - "privilege": "saved_object:foo-type/get", - "resource": "space_2", - }, - Object { - "authorized": false, - "privilege": "saved_object:bar-type/get", - "resource": "space_2", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -636,28 +687,34 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", - }, - Object { - "authorized": false, - "privilege": "saved_object:foo-type/get", - "resource": "space_2", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - Object { - "authorized": false, - "privilege": "saved_object:bar-type/get", - "resource": "space_2", - }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -694,28 +751,34 @@ describe('#atSpaces', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_1", + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": "space_1", - }, - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": "space_2", - }, - Object { - "authorized": false, - "privilege": "saved_object:bar-type/get", - "resource": "space_2", - }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + }, "username": "foo-username", } `); @@ -857,7 +920,7 @@ describe('#globally', () => { let actualResult; let errorThrown = null; try { - actualResult = await checkPrivileges.globally(options.privilegeOrPrivileges); + actualResult = await checkPrivileges.globally({ kibana: options.privilegeOrPrivileges }); } catch (err) { errorThrown = err; } @@ -865,6 +928,7 @@ describe('#globally', () => { expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { body: { + index: [], applications: [ { application, @@ -906,13 +970,19 @@ describe('#globally', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "mock-action:login", - "resource": undefined, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": undefined, + }, + ], + }, "username": "foo-username", } `); @@ -937,13 +1007,19 @@ describe('#globally', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": false, - "privilege": "mock-action:login", - "resource": undefined, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - ], + "kibana": Array [ + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": undefined, + }, + ], + }, "username": "foo-username", } `); @@ -1018,18 +1094,24 @@ describe('#globally', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": true, - "privileges": Array [ - Object { - "authorized": true, - "privilege": "saved_object:foo-type/get", - "resource": undefined, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": undefined, - }, - ], + "kibana": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": undefined, + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": undefined, + }, + ], + }, "username": "foo-username", } `); @@ -1059,18 +1141,24 @@ describe('#globally', () => { expect(result).toMatchInlineSnapshot(` Object { "hasAllRequested": false, - "privileges": Array [ - Object { - "authorized": false, - "privilege": "saved_object:foo-type/get", - "resource": undefined, + "privileges": Object { + "elasticsearch": Object { + "cluster": Array [], + "index": Object {}, }, - Object { - "authorized": true, - "privilege": "saved_object:bar-type/get", - "resource": undefined, - }, - ], + "kibana": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": undefined, + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": undefined, + }, + ], + }, "username": "foo-username", } `); diff --git a/x-pack/plugins/security/server/authorization/check_privileges.ts b/x-pack/plugins/security/server/authorization/check_privileges.ts index 44e9438bd07f5c5..484a73d316ec0c0 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.ts @@ -8,7 +8,11 @@ import { pick, transform, uniq } from 'lodash'; import { IClusterClient, KibanaRequest } from '../../../../../src/core/server'; import { GLOBAL_RESOURCE } from '../../common/constants'; import { ResourceSerializer } from './resource_serializer'; -import { HasPrivilegesResponse, HasPrivilegesResponseApplication } from './types'; +import { + HasPrivilegesResponse, + HasPrivilegesResponseApplication, + CheckPrivilegesPayload, +} from './types'; import { validateEsPrivilegeResponse } from './validate_es_response'; interface CheckPrivilegesActions { @@ -19,28 +23,39 @@ interface CheckPrivilegesActions { export interface CheckPrivilegesResponse { hasAllRequested: boolean; username: string; - privileges: Array<{ - /** - * If this attribute is undefined, this element is a privilege for the global resource. - */ - resource?: string; - privilege: string; - authorized: boolean; - }>; + privileges: { + kibana: Array<{ + /** + * If this attribute is undefined, this element is a privilege for the global resource. + */ + resource?: string; + privilege: string; + authorized: boolean; + }>; + elasticsearch: { + cluster: Array<{ + privilege: string; + authorized: boolean; + }>; + index: { + [indexName: string]: Array<{ + privilege: string; + authorized: boolean; + }>; + }; + }; + }; } export type CheckPrivilegesWithRequest = (request: KibanaRequest) => CheckPrivileges; export interface CheckPrivileges { - atSpace( - spaceId: string, - privilegeOrPrivileges: string | string[] - ): Promise; + atSpace(spaceId: string, privileges: CheckPrivilegesPayload): Promise; atSpaces( spaceIds: string[], - privilegeOrPrivileges: string | string[] + privileges: CheckPrivilegesPayload ): Promise; - globally(privilegeOrPrivileges: string | string[]): Promise; + globally(privileges: CheckPrivilegesPayload): Promise; } export function checkPrivilegesWithRequestFactory( @@ -59,17 +74,26 @@ export function checkPrivilegesWithRequestFactory( return function checkPrivilegesWithRequest(request: KibanaRequest): CheckPrivileges { const checkPrivilegesAtResources = async ( resources: string[], - privilegeOrPrivileges: string | string[] + privileges: CheckPrivilegesPayload ): Promise => { - const privileges = Array.isArray(privilegeOrPrivileges) - ? privilegeOrPrivileges - : [privilegeOrPrivileges]; - const allApplicationPrivileges = uniq([actions.version, actions.login, ...privileges]); + const kibanaPrivileges = Array.isArray(privileges.kibana) + ? privileges.kibana + : privileges.kibana + ? [privileges.kibana] + : []; + const allApplicationPrivileges = uniq([actions.version, actions.login, ...kibanaPrivileges]); const hasPrivilegesResponse = (await clusterClient .asScoped(request) .callAsCurrentUser('shield.hasPrivileges', { body: { + cluster: privileges.elasticsearch?.cluster, + index: Object.entries(privileges.elasticsearch?.index ?? {}).map( + ([names, indexPrivileges]) => ({ + names, + privileges: indexPrivileges, + }) + ), applications: [ { application: applicationName, resources, privileges: allApplicationPrivileges }, ], @@ -85,6 +109,28 @@ export function checkPrivilegesWithRequestFactory( const applicationPrivilegesResponse = hasPrivilegesResponse.application[applicationName]; + const clusterPrivilegesResponse = hasPrivilegesResponse.cluster ?? {}; + + const clusterPrivileges = Object.entries(clusterPrivilegesResponse).map( + ([privilege, authorized]) => ({ + privilege, + authorized, + }) + ); + + const indexPrivileges = Object.entries(hasPrivilegesResponse.index ?? {}).reduce( + (acc, [index, indexResponse]) => { + return { + ...acc, + [index]: Object.entries(indexResponse).map(([privilege, authorized]) => ({ + privilege, + authorized, + })), + }; + }, + {} as CheckPrivilegesResponse['privileges']['elasticsearch']['index'] + ); + if (hasIncompatibleVersion(applicationPrivilegesResponse)) { throw new Error( 'Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.' @@ -93,7 +139,7 @@ export function checkPrivilegesWithRequestFactory( // we need to filter out the non requested privileges from the response const resourcePrivileges = transform(applicationPrivilegesResponse, (result, value, key) => { - result[key!] = pick(value, privileges); + result[key!] = pick(value, privileges.kibana ?? []); }) as HasPrivilegesResponseApplication; const privilegeArray = Object.entries(resourcePrivileges) .map(([key, val]) => { @@ -111,23 +157,29 @@ export function checkPrivilegesWithRequestFactory( return { hasAllRequested: hasPrivilegesResponse.has_all_requested, username: hasPrivilegesResponse.username, - privileges: privilegeArray, + privileges: { + kibana: privilegeArray, + elasticsearch: { + cluster: clusterPrivileges, + index: indexPrivileges, + }, + }, }; }; return { - async atSpace(spaceId: string, privilegeOrPrivileges: string | string[]) { + async atSpace(spaceId: string, privileges: CheckPrivilegesPayload) { const spaceResource = ResourceSerializer.serializeSpaceResource(spaceId); - return await checkPrivilegesAtResources([spaceResource], privilegeOrPrivileges); + return await checkPrivilegesAtResources([spaceResource], privileges); }, - async atSpaces(spaceIds: string[], privilegeOrPrivileges: string | string[]) { + async atSpaces(spaceIds: string[], privileges: CheckPrivilegesPayload) { const spaceResources = spaceIds.map((spaceId) => ResourceSerializer.serializeSpaceResource(spaceId) ); - return await checkPrivilegesAtResources(spaceResources, privilegeOrPrivileges); + return await checkPrivilegesAtResources(spaceResources, privileges); }, - async globally(privilegeOrPrivileges: string | string[]) { - return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privilegeOrPrivileges); + async globally(privileges: CheckPrivilegesPayload) { + return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privileges); }, }; }; diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts index 2206748597635b4..093b308f59391ec 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts @@ -24,11 +24,13 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { namespaceToSpaceId: jest.fn(), }) )(request); - const result = await checkPrivilegesDynamically(privilegeOrPrivileges); + const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }); expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, privilegeOrPrivileges); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, { + kibana: privilegeOrPrivileges, + }); }); test(`checkPrivileges.globally when spaces is disabled`, async () => { @@ -43,9 +45,9 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { mockCheckPrivilegesWithRequest, () => undefined )(request); - const result = await checkPrivilegesDynamically(privilegeOrPrivileges); + const result = await checkPrivilegesDynamically({ kibana: privilegeOrPrivileges }); expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(privilegeOrPrivileges); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: privilegeOrPrivileges }); }); diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts index 6014bad739e7760..579208becd517f3 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts @@ -7,9 +7,10 @@ import { KibanaRequest } from '../../../../../src/core/server'; import { SpacesService } from '../plugin'; import { CheckPrivilegesResponse, CheckPrivilegesWithRequest } from './check_privileges'; +import { CheckPrivilegesPayload } from './types'; export type CheckPrivilegesDynamically = ( - privilegeOrPrivileges: string | string[] + privileges: CheckPrivilegesPayload ) => Promise; export type CheckPrivilegesDynamicallyWithRequest = ( @@ -22,11 +23,11 @@ export function checkPrivilegesDynamicallyWithRequestFactory( ): CheckPrivilegesDynamicallyWithRequest { return function checkPrivilegesDynamicallyWithRequest(request: KibanaRequest) { const checkPrivileges = checkPrivilegesWithRequest(request); - return async function checkPrivilegesDynamically(privilegeOrPrivileges: string | string[]) { + return async function checkPrivilegesDynamically(privileges: CheckPrivilegesPayload) { const spacesService = getSpacesService(); return spacesService - ? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privilegeOrPrivileges) - : await checkPrivileges.globally(privilegeOrPrivileges); + ? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privileges) + : await checkPrivileges.globally(privileges); }; }; } diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts index 4ab00b511b48ba9..8ec48b16bb57b37 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts @@ -79,7 +79,7 @@ describe('#checkSavedObjectsPrivileges', () => { expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledTimes(1); const spaceIds = mockSpacesService!.namespaceToSpaceId.mock.results.map((x) => x.value); - expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, actions); + expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, { kibana: actions }); }); }); @@ -98,7 +98,7 @@ describe('#checkSavedObjectsPrivileges', () => { expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivileges.atSpace).toHaveBeenCalledTimes(1); const spaceId = mockSpacesService!.namespaceToSpaceId.mock.results[0].value; - expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, actions); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, { kibana: actions }); }); test(`uses checkPrivileges.globally when Spaces is disabled`, async () => { @@ -113,7 +113,7 @@ describe('#checkSavedObjectsPrivileges', () => { expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivileges.globally).toHaveBeenCalledTimes(1); - expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(actions); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: actions }); }); }); }); diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts index d9b070c72f94631..2f88e8823db1a89 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts @@ -38,12 +38,12 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = ( throw new Error(`Can't check saved object privileges for 0 namespaces`); } const spaceIds = namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x)); - return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, actions); + return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, { kibana: actions }); } else if (spacesService) { const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces); - return await checkPrivilegesWithRequest(request).atSpace(spaceId, actions); + return await checkPrivilegesWithRequest(request).atSpace(spaceId, { kibana: actions }); } - return await checkPrivilegesWithRequest(request).globally(actions); + return await checkPrivilegesWithRequest(request).globally({ kibana: actions }); }; }; }; diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index a1bedea9f7debe5..a5a519cec65cfcc 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -9,11 +9,15 @@ import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; import { httpServerMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { authorizationMock } from './index.mock'; -import { Feature } from '../../../features/server'; +import { Feature, ElasticsearchFeature } from '../../../features/server'; type MockAuthzOptions = | { rejectCheckPrivileges: any } - | { resolveCheckPrivileges: { privileges: Array<{ privilege: string; authorized: boolean }> } }; + | { + resolveCheckPrivileges: { + privileges: { kibana: Array<{ privilege: string; authorized: boolean }> }; + }; + }; const actions = new Actions('1.0.0-zeta1'); const mockRequest = httpServerMock.createKibanaRequest(); @@ -28,11 +32,15 @@ const createMockAuthz = (options: MockAuthzOptions) => { throw options.rejectCheckPrivileges; } - const expected = options.resolveCheckPrivileges.privileges.map((x) => x.privilege); - expect(checkActions).toEqual(expected); + const expected = options.resolveCheckPrivileges.privileges.kibana.map((x) => x.privilege); + expect(checkActions).toEqual({ kibana: expected, elasticsearch: { cluster: [], index: {} } }); return options.resolveCheckPrivileges; }); }); + mock.checkElasticsearchPrivilegesWithRequest.mockImplementation((request) => { + expect(request).toBe(mockRequest); + return jest.fn().mockImplementation((privileges) => {}); + }); return mock; }; @@ -55,6 +63,21 @@ describe('usingPrivileges', () => { privileges: null, }), ], + [ + new ElasticsearchFeature({ + id: 'esFeature', + privileges: { + all: { + requiredClusterPrivileges: [], + ui: [], + }, + read: { + requiredClusterPrivileges: [], + ui: [], + }, + }, + }), + ], mockLoggers.get(), mockAuthz ); @@ -129,6 +152,21 @@ describe('usingPrivileges', () => { privileges: null, }), ], + [ + new ElasticsearchFeature({ + id: 'esFeature', + privileges: { + all: { + requiredClusterPrivileges: [], + ui: [], + }, + read: { + requiredClusterPrivileges: [], + ui: [], + }, + }, + }), + ], mockLoggers.get(), mockAuthz ); @@ -194,6 +232,7 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [], + [], mockLoggers.get(), mockAuthz ); @@ -229,17 +268,19 @@ describe('usingPrivileges', () => { test(`disables ui capabilities when they don't have privileges`, async () => { const mockAuthz = createMockAuthz({ resolveCheckPrivileges: { - privileges: [ - { privilege: actions.ui.get('navLinks', 'foo'), authorized: true }, - { privilege: actions.ui.get('navLinks', 'bar'), authorized: false }, - { privilege: actions.ui.get('navLinks', 'quz'), authorized: false }, - { privilege: actions.ui.get('management', 'kibana', 'indices'), authorized: true }, - { privilege: actions.ui.get('management', 'kibana', 'settings'), authorized: false }, - { privilege: actions.ui.get('fooFeature', 'foo'), authorized: true }, - { privilege: actions.ui.get('fooFeature', 'bar'), authorized: false }, - { privilege: actions.ui.get('barFeature', 'foo'), authorized: true }, - { privilege: actions.ui.get('barFeature', 'bar'), authorized: false }, - ], + privileges: { + kibana: [ + { privilege: actions.ui.get('navLinks', 'foo'), authorized: true }, + { privilege: actions.ui.get('navLinks', 'bar'), authorized: false }, + { privilege: actions.ui.get('navLinks', 'quz'), authorized: false }, + { privilege: actions.ui.get('management', 'kibana', 'indices'), authorized: true }, + { privilege: actions.ui.get('management', 'kibana', 'settings'), authorized: false }, + { privilege: actions.ui.get('fooFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('fooFeature', 'bar'), authorized: false }, + { privilege: actions.ui.get('barFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('barFeature', 'bar'), authorized: false }, + ], + }, }, }); @@ -261,6 +302,21 @@ describe('usingPrivileges', () => { privileges: null, }), ], + [ + new ElasticsearchFeature({ + id: 'esFeature', + privileges: { + all: { + requiredClusterPrivileges: [], + ui: [], + }, + read: { + requiredClusterPrivileges: [], + ui: [], + }, + }, + }), + ], loggingSystemMock.create().get(), mockAuthz ); @@ -317,15 +373,17 @@ describe('usingPrivileges', () => { test(`doesn't re-enable disabled uiCapabilities`, async () => { const mockAuthz = createMockAuthz({ resolveCheckPrivileges: { - privileges: [ - { privilege: actions.ui.get('navLinks', 'foo'), authorized: true }, - { privilege: actions.ui.get('navLinks', 'bar'), authorized: true }, - { privilege: actions.ui.get('management', 'kibana', 'indices'), authorized: true }, - { privilege: actions.ui.get('fooFeature', 'foo'), authorized: true }, - { privilege: actions.ui.get('fooFeature', 'bar'), authorized: true }, - { privilege: actions.ui.get('barFeature', 'foo'), authorized: true }, - { privilege: actions.ui.get('barFeature', 'bar'), authorized: true }, - ], + privileges: { + kibana: [ + { privilege: actions.ui.get('navLinks', 'foo'), authorized: true }, + { privilege: actions.ui.get('navLinks', 'bar'), authorized: true }, + { privilege: actions.ui.get('management', 'kibana', 'indices'), authorized: true }, + { privilege: actions.ui.get('fooFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('fooFeature', 'bar'), authorized: true }, + { privilege: actions.ui.get('barFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('barFeature', 'bar'), authorized: true }, + ], + }, }, }); @@ -347,6 +405,21 @@ describe('usingPrivileges', () => { privileges: null, }), ], + [ + new ElasticsearchFeature({ + id: 'esFeature', + privileges: { + all: { + requiredClusterPrivileges: [], + ui: [], + }, + read: { + requiredClusterPrivileges: [], + ui: [], + }, + }, + }), + ], loggingSystemMock.create().get(), mockAuthz ); @@ -412,6 +485,21 @@ describe('all', () => { privileges: null, }), ], + [ + new ElasticsearchFeature({ + id: 'esFeature', + privileges: { + all: { + requiredClusterPrivileges: [], + ui: [], + }, + read: { + requiredClusterPrivileges: [], + ui: [], + }, + }, + }), + ], loggingSystemMock.create().get(), mockAuthz ); diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts index 183ad9169a1233e..86864ff11961e84 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts @@ -7,7 +7,11 @@ import { flatten, isObject, mapValues } from 'lodash'; import { UICapabilities } from 'ui/capabilities'; import { KibanaRequest, Logger } from '../../../../../src/core/server'; -import { Feature } from '../../../features/server'; +import { + Feature, + ElasticsearchFeature, + FeatureElasticsearchPrivileges, +} from '../../../features/server'; import { CheckPrivilegesResponse } from './check_privileges'; import { AuthorizationServiceSetup } from '.'; @@ -15,6 +19,7 @@ import { AuthorizationServiceSetup } from '.'; export function disableUICapabilitiesFactory( request: KibanaRequest, features: Feature[], + elasticsearchFeatures: ElasticsearchFeature[], logger: Logger, authz: AuthorizationServiceSetup ) { @@ -22,6 +27,35 @@ export function disableUICapabilitiesFactory( .map((feature) => feature.navLinkId) .filter((navLinkId) => navLinkId != null); + const elasticsearchFeatureMap = elasticsearchFeatures.reduce((acc, esFeature) => { + return { + ...acc, + [esFeature.id]: esFeature.privileges.read, + }; + }, {} as Record); + + const allRequiredClusterPrivileges = Array.from( + new Set( + Object.values(elasticsearchFeatureMap) + .map((p) => p.requiredClusterPrivileges) + .flat() + ) + ); + + const allRequiredIndexPrivileges = Object.values(elasticsearchFeatureMap) + .filter((p) => !!p.requiredIndexPrivileges) + .reduce((acc, p) => { + return { + ...acc, + ...Object.entries(p.requiredIndexPrivileges!).reduce((acc2, [indexName, privileges]) => { + return { + ...acc2, + [indexName]: [...(acc[indexName] ?? []), ...privileges], + }; + }, {} as Record), + }; + }, {} as Record); + const shouldDisableFeatureUICapability = ( featureId: keyof UICapabilities, uiCapability: string @@ -82,7 +116,13 @@ export function disableUICapabilitiesFactory( let checkPrivilegesResponse: CheckPrivilegesResponse; try { const checkPrivilegesDynamically = authz.checkPrivilegesDynamicallyWithRequest(request); - checkPrivilegesResponse = await checkPrivilegesDynamically(uiActions); + checkPrivilegesResponse = await checkPrivilegesDynamically({ + kibana: uiActions, + elasticsearch: { + cluster: allRequiredClusterPrivileges, + index: allRequiredIndexPrivileges, + }, + }); } catch (err) { // if we get a 401/403, then we want to disable all uiCapabilities, as this // is generally when the user hasn't authenticated yet and we're displaying the @@ -106,8 +146,31 @@ export function disableUICapabilitiesFactory( return false; } + if (featureId === 'management' && uiCapabilityParts.length === 2) { + const [, managementFeatureId] = uiCapabilityParts; + if (elasticsearchFeatureMap.hasOwnProperty(managementFeatureId)) { + const hasRequiredClusterPrivileges = elasticsearchFeatureMap[ + managementFeatureId + ].requiredClusterPrivileges.every((expectedClusterPriv) => + checkPrivilegesResponse.privileges.elasticsearch.cluster.some( + (x) => x.privilege === expectedClusterPriv && x.authorized === true + ) + ); + + const hasRequiredIndexPrivileges = Object.entries( + elasticsearchFeatureMap[managementFeatureId].requiredIndexPrivileges ?? {} + ).every(([indexName, requiredIndexPrivileges]) => { + return checkPrivilegesResponse.privileges.elasticsearch.index[indexName] + .filter((indexResponse) => requiredIndexPrivileges.includes(indexResponse.privilege)) + .every((indexResponse) => indexResponse.authorized); + }); + + return hasRequiredClusterPrivileges && hasRequiredIndexPrivileges; + } + } + const action = authz.actions.ui.get(featureId, ...uiCapabilityParts); - return checkPrivilegesResponse.privileges.some( + return checkPrivilegesResponse.privileges.kibana.some( (x) => x.privilege === action && x.authorized === true ); }; diff --git a/x-pack/plugins/security/server/authorization/index.mock.ts b/x-pack/plugins/security/server/authorization/index.mock.ts index 930ede4157723a6..a9c436c818629b6 100644 --- a/x-pack/plugins/security/server/authorization/index.mock.ts +++ b/x-pack/plugins/security/server/authorization/index.mock.ts @@ -14,6 +14,7 @@ export const authorizationMock = { }: { version?: string; applicationName?: string } = {}) => ({ actions: new Actions(version), checkPrivilegesWithRequest: jest.fn(), + checkElasticsearchPrivilegesWithRequest: jest.fn(), checkPrivilegesDynamicallyWithRequest: jest.fn(), checkSavedObjectsPrivilegesWithRequest: jest.fn(), applicationName, diff --git a/x-pack/plugins/security/server/authorization/types.ts b/x-pack/plugins/security/server/authorization/types.ts index 75188d1191b1a3b..9616ee0c8c844f8 100644 --- a/x-pack/plugins/security/server/authorization/types.ts +++ b/x-pack/plugins/security/server/authorization/types.ts @@ -16,4 +16,20 @@ export interface HasPrivilegesResponse { application: { [applicationName: string]: HasPrivilegesResponseApplication; }; + cluster?: { + [privilegeName: string]: boolean; + }; + index?: { + [indexName: string]: { + [privilegeName: string]: boolean; + }; + }; +} + +export interface CheckPrivilegesPayload { + kibana?: string | string[]; + elasticsearch?: { + cluster: string[]; + index: Record; + }; } diff --git a/x-pack/plugins/security/server/features/index.ts b/x-pack/plugins/security/server/features/index.ts new file mode 100644 index 000000000000000..3fe097c2bec121e --- /dev/null +++ b/x-pack/plugins/security/server/features/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export { securityFeatures } from './security_features'; diff --git a/x-pack/plugins/security/server/features/security_features.ts b/x-pack/plugins/security/server/features/security_features.ts new file mode 100644 index 000000000000000..c60e75c4be1c8c8 --- /dev/null +++ b/x-pack/plugins/security/server/features/security_features.ts @@ -0,0 +1,94 @@ +/* + * 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 { ElasticsearchFeatureConfig } from '../../../features/server'; + +const userManagementFeature: ElasticsearchFeatureConfig = { + id: 'users', + management: { + security: ['users'], + }, + privileges: { + all: { + requiredClusterPrivileges: ['manage_security'], + management: { + security: ['users'], + }, + ui: [], + }, + read: { + requiredClusterPrivileges: ['manage_security'], + management: { + security: ['users'], + }, + ui: [], + }, + }, +}; + +const rolesManagementFeature: ElasticsearchFeatureConfig = { + id: 'roles', + management: { + security: ['roles'], + }, + privileges: { + all: { + requiredClusterPrivileges: ['manage_security'], + management: { + security: ['roles'], + }, + ui: [], + }, + read: { + requiredClusterPrivileges: ['manage_security'], + management: { + security: ['roles'], + }, + ui: [], + }, + }, +}; + +const apiKeysManagementFeature: ElasticsearchFeatureConfig = { + id: 'api_keys', + management: { + security: ['api_keys'], + }, + privileges: { + all: { + requiredClusterPrivileges: ['manage_api_key'], + ui: [], + }, + read: { + requiredClusterPrivileges: ['manage_own_api_key'], + ui: [], + }, + }, +}; + +const roleMappingsManagementFeature: ElasticsearchFeatureConfig = { + id: 'role_mappings', + management: { + security: ['role_mappings'], + }, + privileges: { + all: { + requiredClusterPrivileges: ['manage_security'], + ui: [], + }, + read: { + requiredClusterPrivileges: ['manage_security'], + ui: [], + }, + }, +}; + +export const securityFeatures = [ + userManagementFeature, + rolesManagementFeature, + apiKeysManagementFeature, + roleMappingsManagementFeature, +]; diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index a14617c8489ccbd..b81d87b0e4bca34 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -16,6 +16,7 @@ import { PluginInitializerContext, } from '../../../../src/core/server'; import { SpacesPluginSetup } from '../../spaces/server'; +import { PluginSetupContract as FeaturesSetupContract } from '../../features/server'; import { PluginSetupContract as FeaturesPluginSetup, PluginStartContract as FeaturesPluginStart, @@ -31,12 +32,18 @@ import { setupSavedObjects } from './saved_objects'; import { AuditService, SecurityAuditLogger, AuditServiceSetup } from './audit'; import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; import { SecurityFeatureUsageService, SecurityFeatureUsageServiceStart } from './feature_usage'; +import { securityFeatures } from './features'; export type SpacesService = Pick< SpacesPluginSetup['spacesService'], 'getSpaceId' | 'namespaceToSpaceId' >; +export type FeaturesService = Pick< + FeaturesSetupContract, + 'getFeatures' | 'getElasticsearchFeatures' +>; + /** * Describes public Security plugin contract returned at the `setup` stage. */ @@ -136,6 +143,10 @@ export class Plugin { license$: licensing.license$, }); + securityFeatures.forEach((securityFeature) => + features.registerElasticsearchFeature(securityFeature) + ); + this.featureUsageService.setup({ featureUsage: licensing.featureUsage }); const audit = this.auditService.setup({ license, config: config.audit }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index c646cd95228f0e4..53f2061c1c6d4e0 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -205,15 +205,17 @@ function getMockCheckPrivilegesSuccess(actions: string | string[], namespaces?: return { hasAllRequested: true, username: USERNAME, - privileges: _namespaces - .map((resource) => - _actions.map((action) => ({ - resource, - privilege: action, - authorized: true, - })) - ) - .flat(), + privileges: { + kibana: _namespaces + .map((resource) => + _actions.map((action) => ({ + resource, + privilege: action, + authorized: true, + })) + ) + .flat(), + }, }; } @@ -229,15 +231,17 @@ function getMockCheckPrivilegesFailure(actions: string | string[], namespaces?: return { hasAllRequested: false, username: USERNAME, - privileges: _namespaces - .map((resource, idxa) => - _actions.map((action, idxb) => ({ - resource, - privilege: action, - authorized: idxa > 0 || idxb > 0, - })) - ) - .flat(), + privileges: { + kibana: _namespaces + .map((resource, idxa) => + _actions.map((action, idxb) => ({ + resource, + privilege: action, + authorized: idxa > 0 || idxb > 0, + })) + ) + .flat(), + }, }; } diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 969344afae5e379..0b34902a6b5e3d4 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -214,12 +214,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const { hasAllRequested, username, privileges } = result; const spaceIds = uniq( - privileges.map(({ resource }) => resource).filter((x) => x !== undefined) + privileges.kibana.map(({ resource }) => resource).filter((x) => x !== undefined) ).sort() as string[]; const isAuthorized = (requiresAll && hasAllRequested) || - (!requiresAll && privileges.some(({ authorized }) => authorized)); + (!requiresAll && privileges.kibana.some(({ authorized }) => authorized)); if (isAuthorized) { this.auditLogger.savedObjectsAuthorizationSuccess( username, @@ -247,7 +247,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } private getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { - return privileges + return privileges.kibana .filter(({ authorized }) => !authorized) .map(({ resource, privilege }) => ({ spaceId: resource, privilege })); } @@ -260,7 +260,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const action = this.actions.login; const checkPrivilegesResult = await this.checkPrivileges(action, namespaces); // check if the user can log into each namespace - const map = checkPrivilegesResult.privileges.reduce( + const map = checkPrivilegesResult.privileges.kibana.reduce( (acc: Record, { resource, authorized }) => { // there should never be a case where more than one privilege is returned for a given space // if there is, fail-safe (authorized + unauthorized = unauthorized) diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 9fe7307e8cb6dae..821daca1583fc50 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -123,7 +123,7 @@ export class Plugin implements IPlugin public async setup( { http, getStartServices }: CoreSetup, - { licensing, security, cloud }: Dependencies + { licensing, features, security, cloud }: Dependencies ): Promise { const pluginConfig = await this.context.config .create() @@ -81,6 +81,23 @@ export class SnapshotRestoreServerPlugin implements Plugin } ); + features.registerElasticsearchFeature({ + id: PLUGIN.id, + management: { + data: [PLUGIN.id], + }, + privileges: { + all: { + requiredClusterPrivileges: [...APP_REQUIRED_CLUSTER_PRIVILEGES], + ui: [], + }, + read: { + requiredClusterPrivileges: [...APP_REQUIRED_CLUSTER_PRIVILEGES], + ui: [], + }, + }, + }); + http.registerRouteHandlerContext('snapshotRestore', async (ctx, request) => { this.snapshotRestoreESClient = this.snapshotRestoreESClient ?? (await getCustomEsClient(getStartServices)); diff --git a/x-pack/plugins/snapshot_restore/server/types.ts b/x-pack/plugins/snapshot_restore/server/types.ts index 9710f812abf234a..13d101fb86a7430 100644 --- a/x-pack/plugins/snapshot_restore/server/types.ts +++ b/x-pack/plugins/snapshot_restore/server/types.ts @@ -7,12 +7,14 @@ import { ScopedClusterClient, IRouter } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; import { CloudSetup } from '../../cloud/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { License } from './services'; import { wrapEsError } from './lib'; import { isEsError } from './shared_imports'; export interface Dependencies { licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; security?: SecurityPluginSetup; cloud?: CloudSetup; } diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index 25fc3ad97c0d93f..19eb3556f1c6e2a 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -38,9 +38,9 @@ export class SpacesClient { public async canEnumerateSpaces(): Promise { if (this.useRbac()) { const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { hasAllRequested } = await checkPrivileges.globally( - this.authorization!.actions.space.manage - ); + const { hasAllRequested } = await checkPrivileges.globally({ + kibana: this.authorization!.actions.space.manage, + }); this.debugLogger(`SpacesClient.canEnumerateSpaces, using RBAC. Result: ${hasAllRequested}`); return hasAllRequested; } @@ -74,9 +74,11 @@ export class SpacesClient { const privilege = privilegeFactory(this.authorization!); - const { username, privileges } = await checkPrivileges.atSpaces(spaceIds, privilege); + const { username, privileges } = await checkPrivileges.atSpaces(spaceIds, { + kibana: privilege, + }); - const authorized = privileges.filter((x) => x.authorized).map((x) => x.resource); + const authorized = privileges.kibana.filter((x) => x.authorized).map((x) => x.resource); this.debugLogger( `SpacesClient.getAll(), authorized for ${ @@ -220,7 +222,7 @@ export class SpacesClient { private async ensureAuthorizedGlobally(action: string, method: string, forbiddenMessage: string) { const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { username, hasAllRequested } = await checkPrivileges.globally(action); + const { username, hasAllRequested } = await checkPrivileges.globally({ kibana: action }); if (hasAllRequested) { this.auditLogger.spacesAuthorizationSuccess(username, method); @@ -238,7 +240,9 @@ export class SpacesClient { forbiddenMessage: string ) { const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { username, hasAllRequested } = await checkPrivileges.atSpace(spaceId, action); + const { username, hasAllRequested } = await checkPrivileges.atSpace(spaceId, { + kibana: action, + }); if (hasAllRequested) { this.auditLogger.spacesAuthorizationSuccess(username, method, [spaceId]); diff --git a/x-pack/plugins/transform/kibana.json b/x-pack/plugins/transform/kibana.json index 391a95853cc16cf..a2fae326e9d5cd5 100644 --- a/x-pack/plugins/transform/kibana.json +++ b/x-pack/plugins/transform/kibana.json @@ -7,7 +7,8 @@ "data", "home", "licensing", - "management" + "management", + "features" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/transform/server/plugin.ts b/x-pack/plugins/transform/server/plugin.ts index c8057a3e2fae11b..24c6b8b117bcd68 100644 --- a/x-pack/plugins/transform/server/plugin.ts +++ b/x-pack/plugins/transform/server/plugin.ts @@ -19,6 +19,7 @@ import { elasticsearchJsPlugin } from './client/elasticsearch_transform'; import { Dependencies } from './types'; import { ApiRoutes } from './routes'; import { License } from './services'; +import { APP_CLUSTER_PRIVILEGES } from '../common/constants'; declare module 'kibana/server' { interface RequestHandlerContext { @@ -58,7 +59,7 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { this.license = new License(); } - setup({ http, getStartServices }: CoreSetup, { licensing }: Dependencies): {} { + setup({ http, getStartServices }: CoreSetup, { licensing, features }: Dependencies): {} { const router = http.createRouter(); this.license.setup( @@ -75,6 +76,23 @@ export class TransformServerPlugin implements Plugin<{}, void, any, any> { } ); + features.registerElasticsearchFeature({ + id: PLUGIN.id, + management: { + data: [PLUGIN.id], + }, + privileges: { + all: { + requiredClusterPrivileges: [...APP_CLUSTER_PRIVILEGES], + ui: [], + }, + read: { + requiredClusterPrivileges: [...APP_CLUSTER_PRIVILEGES], + ui: [], + }, + }, + }); + this.apiRoutes.setup({ router, license: this.license, diff --git a/x-pack/plugins/transform/server/types.ts b/x-pack/plugins/transform/server/types.ts index 5fcc23a6d9f4848..c3d7434f14f455f 100644 --- a/x-pack/plugins/transform/server/types.ts +++ b/x-pack/plugins/transform/server/types.ts @@ -6,10 +6,12 @@ import { IRouter } from 'src/core/server'; import { LicensingPluginSetup } from '../../licensing/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { License } from './services'; export interface Dependencies { licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; } export interface RouteDependencies { diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index 273036a653aeb88..c4c6f23611f2bda 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -4,6 +4,6 @@ "server": true, "ui": true, "configPath": ["xpack", "upgrade_assistant"], - "requiredPlugins": ["management", "licensing"], + "requiredPlugins": ["management", "licensing", "features"], "optionalPlugins": ["cloud", "usageCollection"] } diff --git a/x-pack/plugins/upgrade_assistant/server/plugin.ts b/x-pack/plugins/upgrade_assistant/server/plugin.ts index 0cdf1ca05feac26..7de4bc1828a3277 100644 --- a/x-pack/plugins/upgrade_assistant/server/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/server/plugin.ts @@ -16,6 +16,7 @@ import { } from '../../../../src/core/server'; import { CloudSetup } from '../../cloud/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { CredentialStore, credentialStoreFactory } from './lib/reindexing/credential_store'; @@ -32,6 +33,7 @@ import { RouteDependencies } from './types'; interface PluginsSetup { usageCollection: UsageCollectionSetup; licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; cloud?: CloudSetup; } @@ -60,13 +62,30 @@ export class UpgradeAssistantServerPlugin implements Plugin { setup( { http, getStartServices, capabilities, savedObjects }: CoreSetup, - { usageCollection, cloud, licensing }: PluginsSetup + { usageCollection, cloud, features, licensing }: PluginsSetup ) { this.licensing = licensing; savedObjects.registerType(reindexOperationSavedObjectType); savedObjects.registerType(telemetrySavedObjectType); + features.registerElasticsearchFeature({ + id: 'upgrade_assistant', + management: { + stack: ['upgrade_assistant'], + }, + privileges: { + all: { + requiredClusterPrivileges: ['manage'], + ui: [], + }, + read: { + requiredClusterPrivileges: ['manage'], + ui: [], + }, + }, + }); + const router = http.createRouter(); const dependencies: RouteDependencies = { diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index d68bbabe82b861e..0aac25724748073 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -27,7 +27,7 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor const { features } = plugins; const libs = compose(server); - features.registerFeature({ + features.registerKibanaFeature({ id: PLUGIN.ID, name: PLUGIN.NAME, order: 1000, diff --git a/x-pack/plugins/watcher/kibana.json b/x-pack/plugins/watcher/kibana.json index a0482ad00797ce9..5efaeb9a028f103 100644 --- a/x-pack/plugins/watcher/kibana.json +++ b/x-pack/plugins/watcher/kibana.json @@ -7,7 +7,8 @@ "licensing", "management", "charts", - "data" + "data", + "features" ], "server": true, "ui": true diff --git a/x-pack/plugins/watcher/server/plugin.ts b/x-pack/plugins/watcher/server/plugin.ts index 67bf9a92abde69e..928e903cfa7c1e8 100644 --- a/x-pack/plugins/watcher/server/plugin.ts +++ b/x-pack/plugins/watcher/server/plugin.ts @@ -18,7 +18,7 @@ import { Plugin, PluginInitializerContext, } from 'kibana/server'; -import { PLUGIN } from '../common/constants'; +import { PLUGIN, INDEX_NAMES } from '../common/constants'; import { Dependencies, LicenseStatus, RouteDependencies } from './types'; import { registerSettingsRoutes } from './routes/api/settings'; @@ -52,13 +52,38 @@ export class WatcherServerPlugin implements Plugin { this.log = ctx.logger.get(); } - async setup({ http, getStartServices }: CoreSetup, { licensing }: Dependencies) { + async setup({ http, getStartServices }: CoreSetup, { licensing, features }: Dependencies) { const router = http.createRouter(); const routeDependencies: RouteDependencies = { router, getLicenseStatus: () => this.licenseStatus, }; + features.registerElasticsearchFeature({ + id: 'watcher', + management: { + insightsAndAlerting: ['watcher'], + }, + privileges: { + all: { + requiredClusterPrivileges: ['manage_watcher'], + requiredIndexPrivileges: { + [INDEX_NAMES.WATCHES]: ['read'], + [INDEX_NAMES.WATCHER_HISTORY]: ['read'], + }, + ui: [], + }, + read: { + requiredClusterPrivileges: ['monitor_watcher'], + requiredIndexPrivileges: { + [INDEX_NAMES.WATCHES]: ['read'], + [INDEX_NAMES.WATCHER_HISTORY]: ['read'], + }, + ui: [], + }, + }, + }); + http.registerRouteHandlerContext('watcher', async (ctx, request) => { this.watcherESClient = this.watcherESClient ?? (await getCustomEsClient(getStartServices)); return { diff --git a/x-pack/plugins/watcher/server/types.ts b/x-pack/plugins/watcher/server/types.ts index dd941054114a846..167dcb3ab64c39f 100644 --- a/x-pack/plugins/watcher/server/types.ts +++ b/x-pack/plugins/watcher/server/types.ts @@ -5,12 +5,14 @@ */ import { IRouter } from 'kibana/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { XPackMainPlugin } from '../../../legacy/plugins/xpack_main/server/xpack_main'; export interface Dependencies { licensing: LicensingPluginSetup; + features: FeaturesPluginSetup; } export interface ServerShim { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index f1ac3f91c68db6f..3b31a640defdacf 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -57,7 +57,7 @@ export class FixturePlugin implements Plugin { public setup(core: CoreSetup, { features, actions, alerts }: FixtureSetupDeps) { - features.registerFeature({ + features.registerKibanaFeature({ id: 'alerts', name: 'Alerts', app: ['alerts', 'kibana'], diff --git a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/server/index.ts b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/server/index.ts index bff794801119af9..1c83ac7fe5a7451 100644 --- a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/server/index.ts +++ b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/server/index.ts @@ -14,7 +14,7 @@ interface SetupDeps { class FooPlugin implements Plugin { setup(core: CoreSetup, plugins: SetupDeps) { - plugins.features.registerFeature({ + plugins.features.registerKibanaFeature({ id: 'foo', name: 'Foo', icon: 'upArrow',