diff --git a/test/functional/page_objects/visualize_page.js b/test/functional/page_objects/visualize_page.js index c09c5e64bdbfe..56e8d101c1186 100644 --- a/test/functional/page_objects/visualize_page.js +++ b/test/functional/page_objects/visualize_page.js @@ -700,12 +700,9 @@ export function VisualizePageProvider({ getService, getPageObjects }) { )); } - async saveVisualizationExpectFail(vizName, { saveAsNew = false } = {}) { - await this.saveVisualization(vizName, { saveAsNew }); - const errorToast = await testSubjects.exists('saveVisualizationError', { - timeout: defaultFindTimeout - }); - expect(errorToast).to.be(true); + async expectNoSaveOption() { + const saveButtonExists = await testSubjects.exists('visualizeSaveButton'); + expect(saveButtonExists).to.be(false); } async clickLoadSavedVisButton() { diff --git a/x-pack/plugins/apm/index.js b/x-pack/plugins/apm/index.js index 0da2f1c876f53..3d6e28154ce41 100644 --- a/x-pack/plugins/apm/index.js +++ b/x-pack/plugins/apm/index.js @@ -71,7 +71,9 @@ export function apm(kibana) { init(server) { server.plugins.xpack_main.registerFeature({ id: 'apm', - name: 'APM', + name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', { + defaultMessage: 'APM' + }), icon: 'apmApp', navLinkId: 'apm', catalogue: ['apm'], @@ -86,7 +88,11 @@ export function apm(kibana) { }, ui: [] } - } + }, + privilegesTooltip: i18n.translate('xpack.apm.privileges.tooltip', { + defaultMessage: + 'A role with access to the apm-* indicies should be assigned to users to grant access' + }) }); initTransactionsApi(server); diff --git a/x-pack/plugins/canvas/init.js b/x-pack/plugins/canvas/init.js index 4dab79a364a79..25313bb3573ac 100644 --- a/x-pack/plugins/canvas/init.js +++ b/x-pack/plugins/canvas/init.js @@ -45,7 +45,7 @@ export default async function(server /*options*/) { all: { app: ['canvas', 'kibana'], savedObject: { - all: ['canvas'], + all: ['canvas-workpad'], read: ['config', 'index-pattern'], }, ui: [], @@ -54,7 +54,7 @@ export default async function(server /*options*/) { app: ['canvas', 'kibana'], savedObject: { all: [], - read: ['config', 'index-pattern', 'canvas'], + read: ['config', 'index-pattern', 'canvas-workpad'], }, ui: [], }, diff --git a/x-pack/plugins/gis/index.js b/x-pack/plugins/gis/index.js index 2d22cd2cff3fd..22df64c7305e2 100644 --- a/x-pack/plugins/gis/index.js +++ b/x-pack/plugins/gis/index.js @@ -11,6 +11,7 @@ import mappings from './mappings.json'; import { checkLicense } from './check_license'; import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; +import { i18n } from '@kbn/i18n'; export function gis(kibana) { @@ -62,7 +63,9 @@ export function gis(kibana) { xpackMainPlugin.registerFeature({ id: 'gis', - name: 'Maps', + name: i18n.translate('xpack.gis.featureRegistry.gisFeatureName', { + defaultMessage: 'Maps', + }), icon: 'gisApp', navLinkId: 'gis', catalogue: ['gis'], diff --git a/x-pack/plugins/graph/index.js b/x-pack/plugins/graph/index.js index d6ab990855828..aab3b123df09f 100644 --- a/x-pack/plugins/graph/index.js +++ b/x-pack/plugins/graph/index.js @@ -5,7 +5,7 @@ */ import { resolve } from 'path'; -import Boom from 'boom'; +import { i18n } from '@kbn/i18n'; import { initServer } from './server'; import mappings from './mappings.json'; @@ -52,7 +52,9 @@ export function graph(kibana) { server.plugins.xpack_main.registerFeature({ id: 'graph', - name: 'Graph', + name: i18n.translate('xpack.graph.featureRegistry.graphFeatureName', { + defaultMessage: 'Graph', + }), icon: 'graphApp', navLinkId: 'graph', catalogue: ['graph'], diff --git a/x-pack/plugins/infra/server/kibana.index.ts b/x-pack/plugins/infra/server/kibana.index.ts index e4ca8287a76c1..160323575fcb1 100644 --- a/x-pack/plugins/infra/server/kibana.index.ts +++ b/x-pack/plugins/infra/server/kibana.index.ts @@ -33,9 +33,10 @@ export const initServerWithKibana = (kbnServer: KbnServer) => { const xpackMainPlugin = kbnServer.plugins.xpack_main; xpackMainPlugin.registerFeature({ id: 'infrastructure', - name: i18n.translate('xpack.infra.linkInfrastructureTitle', { + name: i18n.translate('xpack.infra.featureRegistry.linkInfrastructureTitle', { defaultMessage: 'Infrastructure', }), + icon: 'infraApp', navLinkId: 'infra:home', catalogue: ['infraops'], privileges: { @@ -52,9 +53,10 @@ export const initServerWithKibana = (kbnServer: KbnServer) => { xpackMainPlugin.registerFeature({ id: 'logs', - name: i18n.translate('xpack.infra.linkLogsTitle', { + name: i18n.translate('xpack.infra.featureRegistry.linkLogsTitle', { defaultMessage: 'Logs', }), + icon: 'loggingApp', navLinkId: 'infra:logs', catalogue: ['infralogging'], privileges: { diff --git a/x-pack/plugins/ml/index.js b/x-pack/plugins/ml/index.js index ae2bc26e7a641..950b404d62322 100644 --- a/x-pack/plugins/ml/index.js +++ b/x-pack/plugins/ml/index.js @@ -73,17 +73,15 @@ export const ml = (kibana) => { xpackMainPlugin.registerFeature({ id: 'ml', - name: 'Machine Learning', + name: i18n.translate('xpack.ml.featureRegistry.mlFeatureName', { + defaultMessage: 'Machine Learning', + }), icon: 'machineLearningApp', navLinkId: 'ml', catalogue: ['ml'], privileges: { all: { - metadata: { - tooltip: i18n.translate('xpack.ml.privileges.tooltip', { - defaultMessage: 'The machine_learning_user or machine_learning_admin role should be assigned to grant access' - }) - }, + catalogue: ['ml'], grantWithBaseRead: true, app: ['ml', 'kibana'], savedObject: { @@ -92,7 +90,10 @@ export const ml = (kibana) => { }, ui: [], }, - } + }, + privilegesTooltip: i18n.translate('xpack.ml.privileges.tooltip', { + defaultMessage: 'The machine_learning_user or machine_learning_admin role should also be assigned to users to grant access' + }) }); // Add server routes and initialize the plugin here diff --git a/x-pack/plugins/monitoring/init.js b/x-pack/plugins/monitoring/init.js index 3b85a255a3095..20026f16bb4e3 100644 --- a/x-pack/plugins/monitoring/init.js +++ b/x-pack/plugins/monitoring/init.js @@ -57,17 +57,15 @@ export const init = (monitoringPlugin, server) => { xpackMainPlugin.registerFeature({ id: 'monitoring', - name: 'Monitoring', + name: i18n.translate('xpack.monitoring.featureRegistry.monitoringFeatureName', { + defaultMessage: 'Stack Monitoring', + }), icon: 'monitoringApp', navLinkId: 'monitoring', catalogue: ['monitoring'], privileges: { all: { - metadata: { - tooltip: i18n.translate('xpack.monitoring.privileges.tooltip', { - defaultMessage: 'The monitoring_user role should be assigned to grant access' - }) - }, + catalogue: ['monitoring'], grantWithBaseRead: true, app: ['monitoring', 'kibana'], savedObject: { @@ -76,7 +74,10 @@ export const init = (monitoringPlugin, server) => { }, ui: [], }, - } + }, + privilegesTooltip: i18n.translate('xpack.monitoring.privileges.tooltip', { + defaultMessage: 'The monitoring_user role should also be assigned to users to grant access' + }) }); const bulkUploader = initBulkUploader(kbnServer, server); diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts new file mode 100644 index 0000000000000..1ad9e52919d29 --- /dev/null +++ b/x-pack/plugins/security/common/model/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { Role } from './role'; +export { FeaturesPrivileges, PrivilegeMap, KibanaPrivilegeSpec } from './kibana_privilege'; +export { IndexPrivilege } from './index_privilege'; +export { PrivilegeDefinition } from './privileges/privilege_definition'; +export { GlobalPrivileges } from './privileges/global_privileges'; +export { SpacesPrivileges } from './privileges/spaces_privileges'; +export { FeaturePrivileges, FeaturePrivilegeSet } from './privileges/feature_privileges'; diff --git a/x-pack/plugins/security/common/model/kibana_privilege.ts b/x-pack/plugins/security/common/model/kibana_privilege.ts index 20cac65b4ca79..124dab27c86b8 100644 --- a/x-pack/plugins/security/common/model/kibana_privilege.ts +++ b/x-pack/plugins/security/common/model/kibana_privilege.ts @@ -4,6 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -export type KibanaPrivilege = 'none' | 'read' | 'all'; +import { FeaturePrivilegeSet } from './privileges/feature_privileges'; +export type FeaturesPrivileges = Record>; -export const KibanaAppPrivileges: KibanaPrivilege[] = ['read', 'all']; +export interface PrivilegeMap { + global: Record; + features: FeaturesPrivileges; + space: Record; +} + +export interface KibanaPrivilegeSpec { + spaces: string[]; + base: string[]; + feature: FeaturePrivilegeSet; +} diff --git a/x-pack/plugins/security/common/model/privileges/feature_privileges.ts b/x-pack/plugins/security/common/model/privileges/feature_privileges.ts new file mode 100644 index 0000000000000..1dd5f8b3339f9 --- /dev/null +++ b/x-pack/plugins/security/common/model/privileges/feature_privileges.ts @@ -0,0 +1,39 @@ +/* + * 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. + */ + +interface FeaturePrivilegesMap { + [featureId: string]: { + [privilegeId: string]: string[]; + }; +} + +export interface FeaturePrivilegeSet { + [featureId: string]: string[]; +} + +export class FeaturePrivileges { + constructor(private readonly featurePrivilegesMap: FeaturePrivilegesMap) {} + + public getAllPrivileges(): FeaturePrivilegeSet { + return Object.entries(this.featurePrivilegesMap).reduce((acc, [featureId, privileges]) => { + return { + ...acc, + [featureId]: Object.keys(privileges), + }; + }, {}); + } + + public getPrivileges(featureId: string): string[] { + return Object.keys(this.featurePrivilegesMap[featureId]); + } + + public getActions(featureId: string, privilege: string): string[] { + if (!this.featurePrivilegesMap[featureId]) { + return []; + } + return this.featurePrivilegesMap[featureId][privilege] || []; + } +} diff --git a/x-pack/plugins/security/common/model/privileges/global_privileges.ts b/x-pack/plugins/security/common/model/privileges/global_privileges.ts new file mode 100644 index 0000000000000..3cfaa31361b96 --- /dev/null +++ b/x-pack/plugins/security/common/model/privileges/global_privileges.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export class GlobalPrivileges { + constructor(private readonly globalPrivilegesMap: Record) {} + + public getAllPrivileges(): string[] { + return Object.keys(this.globalPrivilegesMap); + } + + public getActions(privilege: string): string[] { + return this.globalPrivilegesMap[privilege] || []; + } +} diff --git a/x-pack/plugins/security/common/model/privileges/privilege_definition.ts b/x-pack/plugins/security/common/model/privileges/privilege_definition.ts new file mode 100644 index 0000000000000..e01dcf75af72b --- /dev/null +++ b/x-pack/plugins/security/common/model/privileges/privilege_definition.ts @@ -0,0 +1,26 @@ +/* + * 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 { PrivilegeMap } from '../kibana_privilege'; +import { FeaturePrivileges } from './feature_privileges'; +import { GlobalPrivileges } from './global_privileges'; +import { SpacesPrivileges } from './spaces_privileges'; + +export class PrivilegeDefinition { + constructor(private readonly privilegeActionMap: PrivilegeMap) {} + + public getGlobalPrivileges() { + return new GlobalPrivileges(this.privilegeActionMap.global); + } + + public getSpacesPrivileges() { + return new SpacesPrivileges(this.privilegeActionMap.space); + } + + public getFeaturePrivileges() { + return new FeaturePrivileges(this.privilegeActionMap.features); + } +} diff --git a/x-pack/plugins/security/common/model/privileges/spaces_privileges.ts b/x-pack/plugins/security/common/model/privileges/spaces_privileges.ts new file mode 100644 index 0000000000000..4cdae7cca18d7 --- /dev/null +++ b/x-pack/plugins/security/common/model/privileges/spaces_privileges.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export class SpacesPrivileges { + constructor(private readonly spacesPrivilegesMap: Record) {} + + public getAllPrivileges(): string[] { + return Object.keys(this.spacesPrivilegesMap); + } + + public getActions(privilege: string): string[] { + return this.spacesPrivilegesMap[privilege] || []; + } +} diff --git a/x-pack/plugins/security/common/model/role.ts b/x-pack/plugins/security/common/model/role.ts index 5b1094c8c3a0a..ddbb52e266fce 100644 --- a/x-pack/plugins/security/common/model/role.ts +++ b/x-pack/plugins/security/common/model/role.ts @@ -5,7 +5,7 @@ */ import { IndexPrivilege } from './index_privilege'; -import { KibanaPrivilege } from './kibana_privilege'; +import { KibanaPrivilegeSpec } from './kibana_privilege'; export interface Role { name: string; @@ -14,16 +14,13 @@ export interface Role { indices: IndexPrivilege[]; run_as: string[]; }; - kibana: { - global: KibanaPrivilege[]; - space: { - [spaceId: string]: KibanaPrivilege[]; - }; - }; + kibana: KibanaPrivilegeSpec[]; metadata?: { [anyKey: string]: any; }; transient_metadata?: { [anyKey: string]: any; }; + _transform_error?: string[]; + _unrecognized_applications?: string[]; } diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 728b453ba96e1..63583a41ebf73 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -10,6 +10,7 @@ import { getUserProvider } from './server/lib/get_user'; import { initAuthenticateApi } from './server/routes/api/v1/authenticate'; import { initUsersApi } from './server/routes/api/v1/users'; import { initPublicRolesApi } from './server/routes/api/public/roles'; +import { initPrivilegesApi } from './server/routes/api/public/privileges'; import { initIndicesApi } from './server/routes/api/v1/indices'; import { initLoginView } from './server/routes/views/login'; import { initLogoutView } from './server/routes/views/logout'; @@ -199,6 +200,7 @@ export const security = (kibana) => new kibana.Plugin({ initUsersApi(server); initPublicRolesApi(server); initIndicesApi(server); + initPrivilegesApi(server); initLoginView(server, xpackMainPlugin); initLogoutView(server); initLoggedOutView(server); diff --git a/x-pack/plugins/security/public/components/_index.scss b/x-pack/plugins/security/public/components/_index.scss new file mode 100644 index 0000000000000..707dc73de00f2 --- /dev/null +++ b/x-pack/plugins/security/public/components/_index.scss @@ -0,0 +1 @@ +@import './management/users/index'; diff --git a/x-pack/plugins/security/public/components/management/users/_index.scss b/x-pack/plugins/security/public/components/management/users/_index.scss new file mode 100644 index 0000000000000..c5da74aa3f785 --- /dev/null +++ b/x-pack/plugins/security/public/components/management/users/_index.scss @@ -0,0 +1 @@ +@import './users'; diff --git a/x-pack/plugins/security/public/components/management/users/_users.scss b/x-pack/plugins/security/public/components/management/users/_users.scss new file mode 100644 index 0000000000000..d06ec3dd526f1 --- /dev/null +++ b/x-pack/plugins/security/public/components/management/users/_users.scss @@ -0,0 +1,16 @@ +// HACK -- Fix for background color full-height of browser +.secUsersEditPage, +.secUsersListingPage { + min-height: calc(100vh - 70px); +} + +.secUsersListingPage__content { + flex-grow: 0; +} + +.secUsersEditPage__content { + max-width: $secFormWidth; + margin-left: auto; + margin-right: auto; + flex-grow: 0; +} diff --git a/x-pack/plugins/security/public/components/management/users/edit_user.js b/x-pack/plugins/security/public/components/management/users/edit_user.js index dd3da18d947a7..ca3fa858a7cdd 100644 --- a/x-pack/plugins/security/public/components/management/users/edit_user.js +++ b/x-pack/plugins/security/public/components/management/users/edit_user.js @@ -384,8 +384,8 @@ class EditUserUI extends Component { } return ( -
- +
+ diff --git a/x-pack/plugins/security/public/components/management/users/users.js b/x-pack/plugins/security/public/components/management/users/users.js index 20016451fad6b..b8b3d1496917d 100644 --- a/x-pack/plugins/security/public/components/management/users/users.js +++ b/x-pack/plugins/security/public/components/management/users/users.js @@ -91,7 +91,7 @@ class UsersUI extends Component { const { apiClient, intl } = this.props; if (permissionDenied) { return ( -
+
- +
+ diff --git a/x-pack/plugins/security/public/index.scss b/x-pack/plugins/security/public/index.scss index 97e6b9fed728c..c38245720acf5 100644 --- a/x-pack/plugins/security/public/index.scss +++ b/x-pack/plugins/security/public/index.scss @@ -1,10 +1,17 @@ @import 'ui/public/styles/styling_constants'; -// Logged out styles -@import './views/logged_out/index'; +// Prefix all styles with "kbn" to avoid conflicts. +// Examples +// secChart +// secChart__legend +// secChart__legend--small +// secChart__legend-isLoading -// Login styles -@import './views/login/index'; +$secFormWidth: 460px; + +// Public components +@import './components/index'; + +// Public views +@import './views/index'; -// Management styles -@import './views/management/index'; diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/build_role.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/build_role.ts new file mode 100644 index 0000000000000..b5be9ae2eb3c1 --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/build_role.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FeaturePrivilegeSet, Role } from '../../../../common/model'; + +export interface BuildRoleOpts { + spacesPrivileges?: Array<{ + spaces: string[]; + base: string[]; + feature: FeaturePrivilegeSet; + }>; +} + +export const buildRole = (options: BuildRoleOpts = {}) => { + const role: Role = { + name: 'unit test role', + elasticsearch: { + indices: [], + cluster: [], + run_as: [], + }, + kibana: [], + }; + + if (options.spacesPrivileges) { + role.kibana.push(...options.spacesPrivileges); + } else { + role.kibana.push({ + spaces: [], + base: [], + feature: {}, + }); + } + + return role; +}; diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts new file mode 100644 index 0000000000000..3c2582475089b --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts @@ -0,0 +1,52 @@ +/* + * 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 const unrestrictedBasePrivileges = { + base: { + privileges: ['all', 'read'], + canUnassign: true, + }, +}; +export const unrestrictedFeaturePrivileges = { + feature: { + feature1: { + privileges: ['all', 'read'], + canUnassign: true, + }, + feature2: { + privileges: ['all', 'read'], + canUnassign: true, + }, + feature3: { + privileges: ['all'], + canUnassign: true, + }, + }, +}; + +export const fullyRestrictedBasePrivileges = { + base: { + privileges: ['all'], + canUnassign: false, + }, +}; + +export const fullyRestrictedFeaturePrivileges = { + feature: { + feature1: { + privileges: ['all'], + canUnassign: false, + }, + feature2: { + privileges: ['all'], + canUnassign: false, + }, + feature3: { + privileges: ['all'], + canUnassign: false, + }, + }, +}; diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts new file mode 100644 index 0000000000000..a7dbc7c566ddc --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts @@ -0,0 +1,37 @@ +/* + * 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 { PrivilegeDefinition } from '../../../../common/model'; + +export const defaultPrivilegeDefinition = new PrivilegeDefinition({ + global: { + all: ['api:/*', 'ui:/*'], + read: ['ui:/feature1/foo', 'ui:/feature2/foo', 'ui:/feature3/foo/*'], + }, + space: { + all: [ + 'api:/feature1/*', + 'ui:/feature1/*', + 'api:/feature2/*', + 'ui:/feature2/*', + 'ui:/feature3/foo', + 'ui:/feature3/foo/*', + ], + read: ['ui:/feature1/foo', 'ui:/feature2/foo', 'ui:/feature3/foo/bar'], + }, + features: { + feature1: { + all: ['ui:/feature1/foo', 'ui:/feature1/bar'], + read: ['ui:/feature1/foo'], + }, + feature2: { + all: ['ui:/feature2/foo', 'api:/feature2/bar'], + read: ['ui:/feature2/foo'], + }, + feature3: { + all: ['ui:/feature3/foo', 'ui:/feature3/foo/*'], + }, + }, +}); diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/index.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/index.ts new file mode 100644 index 0000000000000..253dcaed9f19e --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/__fixtures__/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { defaultPrivilegeDefinition } from './default_privilege_definition'; +export { buildRole, BuildRoleOpts } from './build_role'; +export * from './common_allowed_privileges'; diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/index.ts similarity index 60% rename from x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.ts rename to x-pack/plugins/security/public/lib/kibana_privilege_calculator/index.ts index 395f14756c547..056a4d3022fc5 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.ts +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/index.ts @@ -4,9 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { cloneDeep } from 'lodash'; -import { Role } from '../../../../../common/model/role'; - -export function copyRole(role: Role) { - return cloneDeep(role); -} +export { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory'; +export * from './kibana_privilege_calculator_types'; diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts new file mode 100644 index 0000000000000..505a1c52fef5d --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts @@ -0,0 +1,296 @@ +/* + * 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 { PrivilegeDefinition, Role } from '../../../common/model'; +import { + buildRole, + defaultPrivilegeDefinition, + fullyRestrictedBasePrivileges, + fullyRestrictedFeaturePrivileges, + unrestrictedBasePrivileges, + unrestrictedFeaturePrivileges, +} from './__fixtures__'; +import { KibanaAllowedPrivilegesCalculator } from './kibana_allowed_privileges_calculator'; +import { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory'; + +const buildAllowedPrivilegesCalculator = ( + role: Role, + privilegeDefinition: PrivilegeDefinition = defaultPrivilegeDefinition +) => { + return new KibanaAllowedPrivilegesCalculator(privilegeDefinition, role); +}; + +const buildEffectivePrivilegesCalculator = ( + role: Role, + privilegeDefinition: PrivilegeDefinition = defaultPrivilegeDefinition +) => { + const factory = new KibanaPrivilegeCalculatorFactory(privilegeDefinition); + return factory.getInstance(role); +}; + +describe('AllowedPrivileges', () => { + it('allows all privileges when none are currently assigned', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: [], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivilegesCalculator(role); + const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); + + const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( + effectivePrivileges.calculateEffectivePrivileges(true) + ); + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + ...unrestrictedFeaturePrivileges, + }, + { + ...unrestrictedBasePrivileges, + ...unrestrictedFeaturePrivileges, + }, + ]); + }); + + it('allows all global base privileges, but just "all" for everything else when global is set to "all"', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivilegesCalculator(role); + const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); + + const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( + effectivePrivileges.calculateEffectivePrivileges(true) + ); + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + ...fullyRestrictedFeaturePrivileges, + }, + { + ...fullyRestrictedBasePrivileges, + ...fullyRestrictedFeaturePrivileges, + }, + ]); + }); + + it(`allows feature privileges to be set to "all" or "read" when global base is "read"`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivilegesCalculator(role); + const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); + + const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( + effectivePrivileges.calculateEffectivePrivileges(true) + ); + + const expectedFeaturePrivileges = { + feature: { + feature1: { + privileges: ['all', 'read'], + canUnassign: false, + }, + feature2: { + privileges: ['all', 'read'], + canUnassign: false, + }, + feature3: { + privileges: ['all'], + canUnassign: true, // feature 3 has no "read" privilege governed by global "all" + }, + }, + }; + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + ...expectedFeaturePrivileges, + }, + { + base: { + privileges: ['all', 'read'], + canUnassign: false, + }, + ...expectedFeaturePrivileges, + }, + ]); + }); + + it(`allows feature privileges to be set to "all" or "read" when space base is "read"`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: [], + feature: {}, + }, + { + spaces: ['foo'], + base: ['read'], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivilegesCalculator(role); + const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); + + const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( + effectivePrivileges.calculateEffectivePrivileges(true) + ); + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + ...unrestrictedFeaturePrivileges, + }, + { + base: { + privileges: ['all', 'read'], + canUnassign: true, + }, + feature: { + feature1: { + privileges: ['all', 'read'], + canUnassign: false, + }, + feature2: { + privileges: ['all', 'read'], + canUnassign: false, + }, + feature3: { + privileges: ['all'], + canUnassign: true, // feature 3 has no "read" privilege governed by space "all" + }, + }, + }, + ]); + }); + + it(`allows space base privilege to be set to "all" or "read" when space base is already "all"`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['foo'], + base: ['all'], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivilegesCalculator(role); + const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); + + const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( + effectivePrivileges.calculateEffectivePrivileges(true) + ); + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + feature: { + feature1: { + privileges: ['all'], + canUnassign: false, + }, + feature2: { + privileges: ['all'], + canUnassign: false, + }, + feature3: { + privileges: ['all'], + canUnassign: false, + }, + }, + }, + ]); + }); + + it(`restricts space feature privileges when global feature privileges are set`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['all'], + feature2: ['read'], + }, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivilegesCalculator(role); + const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); + + const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( + effectivePrivileges.calculateEffectivePrivileges(true) + ); + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + ...unrestrictedFeaturePrivileges, + }, + { + base: { + privileges: ['all', 'read'], + canUnassign: true, + }, + feature: { + feature1: { + privileges: ['all'], + canUnassign: false, + }, + feature2: { + privileges: ['all', 'read'], + canUnassign: false, + }, + feature3: { + privileges: ['all'], + canUnassign: true, // feature 3 has no "read" privilege governed by space "all" + }, + }, + }, + ]); + }); +}); diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts new file mode 100644 index 0000000000000..887bf38c5a8c3 --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts @@ -0,0 +1,152 @@ +/* + * 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 _ from 'lodash'; +import { KibanaPrivilegeSpec, PrivilegeDefinition, Role } from '../../../common/model'; +import { NO_PRIVILEGE_VALUE } from '../../views/management/edit_role/lib/constants'; +import { isGlobalPrivilegeDefinition } from '../privilege_utils'; +import { + AllowedPrivilege, + CalculatedPrivilege, + PRIVILEGE_SOURCE, +} from './kibana_privilege_calculator_types'; +import { areActionsFullyCovered, compareActions } from './privilege_calculator_utils'; + +export class KibanaAllowedPrivilegesCalculator { + // reference to the global privilege definition + private globalPrivilege: KibanaPrivilegeSpec; + + // list of privilege actions that comprise the global base privilege + private assignedGlobalBaseActions: string[]; + + constructor( + private readonly privilegeDefinition: PrivilegeDefinition, + private readonly role: Role + ) { + this.globalPrivilege = this.locateGlobalPrivilege(role); + this.assignedGlobalBaseActions = this.globalPrivilege.base[0] + ? privilegeDefinition.getGlobalPrivileges().getActions(this.globalPrivilege.base[0]) + : []; + } + + public calculateAllowedPrivileges( + effectivePrivileges: CalculatedPrivilege[] + ): AllowedPrivilege[] { + const { kibana = [] } = this.role; + return kibana.map((privilegeSpec, index) => + this.calculateAllowedPrivilege(privilegeSpec, effectivePrivileges[index]) + ); + } + + private calculateAllowedPrivilege( + privilegeSpec: KibanaPrivilegeSpec, + effectivePrivileges: CalculatedPrivilege + ): AllowedPrivilege { + const result: AllowedPrivilege = { + base: { + privileges: [], + canUnassign: true, + }, + feature: {}, + }; + + if (isGlobalPrivilegeDefinition(privilegeSpec)) { + // nothing can impede global privileges + result.base.canUnassign = true; + result.base.privileges = this.privilegeDefinition.getGlobalPrivileges().getAllPrivileges(); + } else { + // space base privileges are restricted based on the assigned global privileges + const spacePrivileges = this.privilegeDefinition.getSpacesPrivileges().getAllPrivileges(); + result.base.canUnassign = this.assignedGlobalBaseActions.length === 0; + result.base.privileges = spacePrivileges.filter(privilege => { + // always allowed to assign the calculated effective privilege + if (privilege === effectivePrivileges.base.actualPrivilege) { + return true; + } + + const privilegeActions = this.getBaseActions(PRIVILEGE_SOURCE.SPACE_BASE, privilege); + return !areActionsFullyCovered(this.assignedGlobalBaseActions, privilegeActions); + }); + } + + const allFeaturePrivileges = this.privilegeDefinition.getFeaturePrivileges().getAllPrivileges(); + result.feature = Object.entries(allFeaturePrivileges).reduce( + (acc, [featureId, featurePrivileges]) => { + return { + ...acc, + [featureId]: this.getAllowedFeaturePrivileges( + effectivePrivileges, + featureId, + featurePrivileges + ), + }; + }, + {} + ); + + return result; + } + + private getAllowedFeaturePrivileges( + effectivePrivileges: CalculatedPrivilege, + featureId: string, + candidateFeaturePrivileges: string[] + ): { privileges: string[]; canUnassign: boolean } { + const effectiveFeaturePrivilegeExplanation = effectivePrivileges.feature[featureId]; + const effectiveFeatureActions = this.getFeatureActions( + featureId, + effectiveFeaturePrivilegeExplanation.actualPrivilege + ); + + const privileges = []; + if (effectiveFeaturePrivilegeExplanation.actualPrivilege !== NO_PRIVILEGE_VALUE) { + // Always allowed to assign the calculated effective privilege + privileges.push(effectiveFeaturePrivilegeExplanation.actualPrivilege); + } + + privileges.push( + ...candidateFeaturePrivileges.filter(privilegeId => { + const candidateActions = this.getFeatureActions(featureId, privilegeId); + return compareActions(effectiveFeatureActions, candidateActions) > 0; + }) + ); + + const result = { + privileges: privileges.sort(), + canUnassign: effectiveFeaturePrivilegeExplanation.actualPrivilege === NO_PRIVILEGE_VALUE, + }; + + return result; + } + + private getBaseActions(source: PRIVILEGE_SOURCE, privilegeId: string) { + switch (source) { + case PRIVILEGE_SOURCE.GLOBAL_BASE: + return this.assignedGlobalBaseActions; + case PRIVILEGE_SOURCE.SPACE_BASE: + return this.privilegeDefinition.getSpacesPrivileges().getActions(privilegeId); + default: + throw new Error( + `Cannot get base actions for unsupported privilege source ${PRIVILEGE_SOURCE[source]}` + ); + } + } + + private getFeatureActions(featureId: string, privilegeId: string): string[] { + return this.privilegeDefinition.getFeaturePrivileges().getActions(featureId, privilegeId); + } + + private locateGlobalPrivilege(role: Role) { + const spacePrivileges = role.kibana; + return ( + spacePrivileges.find(privileges => isGlobalPrivilegeDefinition(privileges)) || { + spaces: [] as string[], + base: [] as string[], + feature: {}, + } + ); + } +} diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts new file mode 100644 index 0000000000000..7792736c8ee2f --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts @@ -0,0 +1,321 @@ +/* + * 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 { KibanaPrivilegeSpec, PrivilegeDefinition, Role } from '../../../common/model'; +import { NO_PRIVILEGE_VALUE } from '../../views/management/edit_role/lib/constants'; +import { isGlobalPrivilegeDefinition } from '../privilege_utils'; +import { buildRole, defaultPrivilegeDefinition } from './__fixtures__'; +import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator'; +import { PRIVILEGE_SOURCE, PrivilegeExplanation } from './kibana_privilege_calculator_types'; + +const buildEffectiveBasePrivilegeCalculator = ( + role: Role, + privilegeDefinition: PrivilegeDefinition = defaultPrivilegeDefinition +) => { + const globalPrivilegeSpec = + role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || + ({ + spaces: ['*'], + base: [], + feature: {}, + } as KibanaPrivilegeSpec); + + const globalActions = globalPrivilegeSpec.base[0] + ? privilegeDefinition.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) + : []; + + return new KibanaBasePrivilegeCalculator(privilegeDefinition, globalPrivilegeSpec, globalActions); +}; + +describe('getMostPermissiveBasePrivilege', () => { + describe('without ignoring assigned', () => { + it('returns "none" when no privileges are granted', () => { + const role = buildRole(); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[0], + false + ); + + expect(result).toEqual({ + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + } as PrivilegeExplanation); + }); + + defaultPrivilegeDefinition + .getGlobalPrivileges() + .getAllPrivileges() + .forEach(globalBasePrivilege => { + it(`returns "${globalBasePrivilege}" when assigned directly at the global privilege`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: [globalBasePrivilege], + feature: {}, + }, + ], + }); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[0], + false + ); + + expect(result).toEqual({ + actualPrivilege: globalBasePrivilege, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + } as PrivilegeExplanation); + }); + }); + + defaultPrivilegeDefinition + .getSpacesPrivileges() + .getAllPrivileges() + .forEach(spaceBasePrivilege => { + it(`returns "${spaceBasePrivilege}" when assigned directly at the space base privilege`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['foo'], + base: [spaceBasePrivilege], + feature: {}, + }, + ], + }); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[0], + false + ); + + expect(result).toEqual({ + actualPrivilege: spaceBasePrivilege, + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: true, + } as PrivilegeExplanation); + }); + }); + + it('returns the global privilege when no space base is defined', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[1], + false + ); + + expect(result).toEqual({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + } as PrivilegeExplanation); + }); + + it('returns the global privilege when it supercedes the space privilege', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['foo'], + base: ['read'], + feature: {}, + }, + ], + }); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[1], + false + ); + + expect(result).toEqual({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + supersededPrivilege: 'read', + supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + } as PrivilegeExplanation); + }); + }); + + describe('ignoring assigned', () => { + it('returns "none" when no privileges are granted', () => { + const role = buildRole(); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[0], + true + ); + + expect(result).toEqual({ + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + } as PrivilegeExplanation); + }); + + defaultPrivilegeDefinition + .getGlobalPrivileges() + .getAllPrivileges() + .forEach(globalBasePrivilege => { + it(`returns "none" when "${globalBasePrivilege}" assigned directly at the global privilege`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: [globalBasePrivilege], + feature: {}, + }, + ], + }); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[0], + true + ); + + expect(result).toEqual({ + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + } as PrivilegeExplanation); + }); + }); + + defaultPrivilegeDefinition + .getSpacesPrivileges() + .getAllPrivileges() + .forEach(spaceBasePrivilege => { + it(`returns "none" when "${spaceBasePrivilege}" when assigned directly at the space base privilege`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['foo'], + base: [spaceBasePrivilege], + feature: {}, + }, + ], + }); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[0], + true + ); + + expect(result).toEqual({ + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: true, + } as PrivilegeExplanation); + }); + }); + + it('returns the global privilege when no space base is defined', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[1], + true + ); + + expect(result).toEqual({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + } as PrivilegeExplanation); + }); + + it('returns the global privilege when it supercedes the space privilege, without indicating override', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['foo'], + base: ['read'], + feature: {}, + }, + ], + }); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[1], + true + ); + + expect(result).toEqual({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + } as PrivilegeExplanation); + }); + + it('returns the global privilege even though it would ordinarly be overriden by space base privilege', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: ['all'], + feature: {}, + }, + ], + }); + const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); + const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + role.kibana[1], + true + ); + + expect(result).toEqual({ + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + } as PrivilegeExplanation); + }); + }); +}); diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_base_privilege_calculator.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_base_privilege_calculator.ts new file mode 100644 index 0000000000000..2c0371a8233b5 --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_base_privilege_calculator.ts @@ -0,0 +1,99 @@ +/* + * 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 _ from 'lodash'; +import { KibanaPrivilegeSpec, PrivilegeDefinition } from '../../../common/model'; +import { NO_PRIVILEGE_VALUE } from '../../views/management/edit_role/lib/constants'; +import { isGlobalPrivilegeDefinition } from '../privilege_utils'; +import { PRIVILEGE_SOURCE, PrivilegeExplanation } from './kibana_privilege_calculator_types'; +import { compareActions } from './privilege_calculator_utils'; + +export class KibanaBasePrivilegeCalculator { + constructor( + private readonly privilegeDefinition: PrivilegeDefinition, + private readonly globalPrivilege: KibanaPrivilegeSpec, + private readonly assignedGlobalBaseActions: string[] + ) {} + + public getMostPermissiveBasePrivilege( + privilegeSpec: KibanaPrivilegeSpec, + ignoreAssigned: boolean + ): PrivilegeExplanation { + const assignedPrivilege = privilegeSpec.base[0] || NO_PRIVILEGE_VALUE; + + // If this is the global privilege definition, then there is nothing to supercede it. + if (isGlobalPrivilegeDefinition(privilegeSpec)) { + if (assignedPrivilege === NO_PRIVILEGE_VALUE || ignoreAssigned) { + return { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + }; + } + return { + actualPrivilege: assignedPrivilege, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + }; + } + + // Otherwise, check to see if the global privilege supercedes this one. + const baseActions = [ + ...this.privilegeDefinition.getSpacesPrivileges().getActions(assignedPrivilege), + ]; + + const globalSupercedes = + this.hasAssignedGlobalBasePrivilege() && + (compareActions(this.assignedGlobalBaseActions, baseActions) < 0 || ignoreAssigned); + + if (globalSupercedes) { + const wasDirectlyAssigned = !ignoreAssigned && baseActions.length > 0; + + return { + actualPrivilege: this.globalPrivilege.base[0], + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + ...this.buildSupercededFields( + wasDirectlyAssigned, + assignedPrivilege, + PRIVILEGE_SOURCE.SPACE_BASE + ), + }; + } + + if (!ignoreAssigned) { + return { + actualPrivilege: assignedPrivilege, + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: true, + }; + } + + return { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: true, + }; + } + + private hasAssignedGlobalBasePrivilege() { + return this.assignedGlobalBaseActions.length > 0; + } + + private buildSupercededFields( + isSuperceding: boolean, + supersededPrivilege?: string, + supersededPrivilegeSource?: PRIVILEGE_SOURCE + ) { + if (!isSuperceding) { + return {}; + } + return { + supersededPrivilege, + supersededPrivilegeSource, + }; + } +} diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts new file mode 100644 index 0000000000000..8a983f69b2262 --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts @@ -0,0 +1,868 @@ +/* + * 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 { KibanaPrivilegeSpec, PrivilegeDefinition, Role } from '../../../common/model'; +import { NO_PRIVILEGE_VALUE } from '../../views/management/edit_role/lib/constants'; +import { isGlobalPrivilegeDefinition } from '../privilege_utils'; +import { buildRole, BuildRoleOpts, defaultPrivilegeDefinition } from './__fixtures__'; +import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator'; +import { KibanaFeaturePrivilegeCalculator } from './kibana_feature_privilege_calculator'; +import { PRIVILEGE_SOURCE } from './kibana_privilege_calculator_types'; + +const buildEffectiveBasePrivilegeCalculator = ( + role: Role, + privilegeDefinition: PrivilegeDefinition = defaultPrivilegeDefinition +) => { + const globalPrivilegeSpec = + role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || + ({ + spaces: ['*'], + base: [], + feature: {}, + } as KibanaPrivilegeSpec); + + const globalActions = globalPrivilegeSpec.base[0] + ? privilegeDefinition.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) + : []; + + return new KibanaBasePrivilegeCalculator(privilegeDefinition, globalPrivilegeSpec, globalActions); +}; + +const buildEffectiveFeaturePrivilegeCalculator = ( + role: Role, + privilegeDefinition: PrivilegeDefinition = defaultPrivilegeDefinition +) => { + const globalPrivilegeSpec = + role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || + ({ + spaces: ['*'], + base: [], + feature: {}, + } as KibanaPrivilegeSpec); + + const globalActions = globalPrivilegeSpec.base[0] + ? privilegeDefinition.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) + : []; + + const rankedFeaturePrivileges = privilegeDefinition.getFeaturePrivileges().getAllPrivileges(); + + return new KibanaFeaturePrivilegeCalculator( + privilegeDefinition, + globalPrivilegeSpec, + globalActions, + rankedFeaturePrivileges + ); +}; + +interface TestOpts { + only?: boolean; + role?: BuildRoleOpts; + privilegeIndex?: number; + ignoreAssigned?: boolean; + result: Record; +} + +function runTest( + description: string, + { + role: roleOpts = {}, + result = {}, + privilegeIndex = 0, + ignoreAssigned = false, + only = false, + }: TestOpts +) { + const fn = only ? it.only : it; + fn(description, () => { + const role = buildRole(roleOpts); + const basePrivilegeCalculator = buildEffectiveBasePrivilegeCalculator(role); + const featurePrivilegeCalculator = buildEffectiveFeaturePrivilegeCalculator(role); + + const baseExplanation = basePrivilegeCalculator.getMostPermissiveBasePrivilege( + role.kibana[privilegeIndex], + // If calculations wish to ignoreAssigned, then we still need to know what the real effective base privilege is + // without ignoring assigned, in order to calculate the correct feature privileges. + false + ); + + const actualResult = featurePrivilegeCalculator.getMostPermissiveFeaturePrivilege( + role.kibana[privilegeIndex], + baseExplanation, + 'feature1', + ignoreAssigned + ); + + expect(actualResult).toEqual(result); + }); +} + +describe('getMostPermissiveFeaturePrivilege', () => { + describe('for global feature privileges, without ignoring assigned', () => { + runTest('returns "none" when no privileges are granted', { + result: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + }); + + runTest('returns "read" when assigned directly to the feature', { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + }, + }, + ], + }, + result: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + }); + + runTest('returns "read" when assigned as the global base privilege', { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + ], + }, + result: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + }); + + runTest( + 'returns "all" when assigned as the global base privilege, which overrides assigned feature privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: { + feature1: ['read'], + }, + }, + ], + }, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + supersededPrivilege: 'read', + supersededPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + }, + } + ); + + runTest( + 'returns "all" when assigned as the feature privilege, which does not override assigned global base privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: { + feature1: ['all'], + }, + }, + ], + }, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + } + ); + }); + + describe('for global feature privileges, ignoring assigned', () => { + runTest('returns "none" when no privileges are granted', { + ignoreAssigned: true, + result: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + }); + + runTest('returns "none" when "read" is assigned directly to the feature', { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + }, + }, + ], + }, + ignoreAssigned: true, + result: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + }); + + runTest('returns "read" when assigned as the global base privilege', { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + ], + }, + ignoreAssigned: true, + result: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + }); + + runTest( + 'returns "all" when assigned as the global base privilege, which overrides assigned feature privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: { + feature1: ['read'], + }, + }, + ], + }, + ignoreAssigned: true, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + } + ); + + runTest( + 'returns "read" when "all" assigned as the feature privilege, which does not override assigned global base privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: { + feature1: ['all'], + }, + }, + ], + }, + ignoreAssigned: true, + result: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + } + ); + }); + + describe('for space feature privileges, without ignoring assigned', () => { + runTest('returns "none" when no privileges are granted', { + role: { + spacesPrivileges: [ + { + spaces: ['marketing'], + base: [], + feature: {}, + }, + ], + }, + result: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }, + }); + + runTest('returns "read" when assigned directly to the feature', { + role: { + spacesPrivileges: [ + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + }, + }, + ], + }, + result: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }, + }); + + runTest('returns "read" when assigned as the global base privilege', { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['marketing'], + base: [], + feature: {}, + }, + ], + }, + privilegeIndex: 1, + result: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + }); + + runTest( + 'returns "all" when assigned as the global base privilege, which overrides assigned global feature privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: { + feature1: ['read'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: {}, + }, + ], + }, + privilegeIndex: 1, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + } + ); + + runTest( + 'returns "all" when assigned as the global base privilege, which overrides assigned space feature privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + }, + }, + ], + }, + privilegeIndex: 1, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + supersededPrivilege: 'read', + supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + }, + } + ); + + runTest( + 'returns "all" when assigned as the global feature privilege, which does not override assigned global base privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: { + feature1: ['all'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: {}, + }, + ], + }, + privilegeIndex: 1, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: false, + }, + } + ); + + runTest( + 'returns "all" when assigned as the space feature privilege, which does not override assigned global base privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['all'], + }, + }, + ], + }, + privilegeIndex: 1, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }, + } + ); + + runTest( + 'returns "all" when assigned as the space base privilege, which does not override assigned global base privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['marketing'], + base: ['all'], + feature: {}, + }, + ], + }, + privilegeIndex: 1, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: false, + }, + } + ); + + runTest( + 'returns "all" when assigned as the global base privilege, which overrides assigned space feature privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + }, + }, + ], + }, + privilegeIndex: 1, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + supersededPrivilege: 'read', + supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + }, + } + ); + + runTest('returns "all" when assigned everywhere, without indicating override', { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: { + feature1: ['all'], + }, + }, + { + spaces: ['marketing'], + base: ['all'], + feature: { + feature1: ['all'], + }, + }, + ], + }, + privilegeIndex: 1, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }, + }); + + runTest('returns "all" when assigned at global feature, overriding space feature', { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['all'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + }, + }, + ], + }, + privilegeIndex: 1, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: false, + supersededPrivilege: 'read', + supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + }, + }); + }); + + describe('for space feature privileges, ignoring assigned', () => { + runTest('returns "none" when no privileges are granted', { + role: { + spacesPrivileges: [ + { + spaces: ['marketing'], + base: [], + feature: {}, + }, + ], + }, + ignoreAssigned: true, + result: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }, + }); + + runTest('returns "none" when "read" assigned directly to the feature', { + role: { + spacesPrivileges: [ + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + }, + }, + ], + }, + ignoreAssigned: true, + result: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }, + }); + + runTest('returns "read" when assigned as the global base privilege', { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['marketing'], + base: [], + feature: {}, + }, + ], + }, + privilegeIndex: 1, + ignoreAssigned: true, + result: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + }); + + runTest( + 'returns "all" when assigned as the global base privilege, which overrides assigned global feature privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: { + feature1: ['read'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: {}, + }, + ], + }, + privilegeIndex: 1, + ignoreAssigned: true, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + } + ); + + runTest( + 'returns "all" when assigned as the global base privilege, which normally overrides assigned space feature privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + }, + }, + ], + }, + privilegeIndex: 1, + ignoreAssigned: true, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + } + ); + + runTest( + 'returns "all" when assigned as the global feature privilege, which does not override assigned global base privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: { + feature1: ['all'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: {}, + }, + ], + }, + privilegeIndex: 1, + ignoreAssigned: true, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: false, + }, + } + ); + + runTest( + 'returns "read" when "all" assigned as the space feature privilege, which normally overrides assigned global base privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['all'], + }, + }, + ], + }, + privilegeIndex: 1, + ignoreAssigned: true, + result: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + } + ); + + runTest( + 'returns "all" when assigned as the space base privilege, which does not override assigned global base privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['marketing'], + base: ['all'], + feature: {}, + }, + ], + }, + privilegeIndex: 1, + ignoreAssigned: true, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: false, + }, + } + ); + + runTest( + 'returns "all" when assigned as the global base privilege, which normally overrides assigned space feature privilege', + { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + }, + }, + ], + }, + privilegeIndex: 1, + ignoreAssigned: true, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + } + ); + + runTest('returns "all" when assigned everywhere, without indicating override', { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: { + feature1: ['all'], + }, + }, + { + spaces: ['marketing'], + base: ['all'], + feature: { + feature1: ['all'], + }, + }, + ], + }, + privilegeIndex: 1, + ignoreAssigned: true, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: false, + }, + }); + + runTest('returns "all" when assigned at global feature, normally overriding space feature', { + role: { + spacesPrivileges: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['all'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + }, + }, + ], + }, + privilegeIndex: 1, + ignoreAssigned: true, + result: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: false, + }, + }); + }); +}); diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts new file mode 100644 index 0000000000000..329e675c9f775 --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts @@ -0,0 +1,202 @@ +/* + * 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 _ from 'lodash'; +import { + FeaturePrivilegeSet, + KibanaPrivilegeSpec, + PrivilegeDefinition, +} from '../../../common/model'; +import { NO_PRIVILEGE_VALUE } from '../../views/management/edit_role/lib/constants'; +import { isGlobalPrivilegeDefinition } from '../privilege_utils'; +import { + PRIVILEGE_SOURCE, + PrivilegeExplanation, + PrivilegeScenario, +} from './kibana_privilege_calculator_types'; +import { areActionsFullyCovered } from './privilege_calculator_utils'; + +export class KibanaFeaturePrivilegeCalculator { + constructor( + private readonly privilegeDefinition: PrivilegeDefinition, + private readonly globalPrivilege: KibanaPrivilegeSpec, + private readonly assignedGlobalBaseActions: string[], + private readonly rankedFeaturePrivileges: FeaturePrivilegeSet + ) {} + + public getMostPermissiveFeaturePrivilege( + privilegeSpec: KibanaPrivilegeSpec, + basePrivilegeExplanation: PrivilegeExplanation, + featureId: string, + ignoreAssigned: boolean + ): PrivilegeExplanation { + const scenarios = this.buildFeaturePrivilegeScenarios( + privilegeSpec, + basePrivilegeExplanation, + featureId, + ignoreAssigned + ); + + const featurePrivileges = this.rankedFeaturePrivileges[featureId] || []; + + // inspect feature privileges in ranked order (most permissive -> least permissive) + for (const featurePrivilege of featurePrivileges) { + const actions = this.privilegeDefinition + .getFeaturePrivileges() + .getActions(featureId, featurePrivilege); + + // check if any of the scenarios satisfy the privilege - first one wins. + for (const scenario of scenarios) { + if (areActionsFullyCovered(scenario.actions, actions)) { + return { + actualPrivilege: featurePrivilege, + actualPrivilegeSource: scenario.actualPrivilegeSource, + isDirectlyAssigned: scenario.isDirectlyAssigned, + ...this.buildSupercededFields( + !scenario.isDirectlyAssigned, + scenario.supersededPrivilege, + scenario.supersededPrivilegeSource + ), + }; + } + } + } + + const isGlobal = isGlobalPrivilegeDefinition(privilegeSpec); + return { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: isGlobal + ? PRIVILEGE_SOURCE.GLOBAL_FEATURE + : PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }; + } + + private buildFeaturePrivilegeScenarios( + privilegeSpec: KibanaPrivilegeSpec, + basePrivilegeExplanation: PrivilegeExplanation, + featureId: string, + ignoreAssigned: boolean + ): PrivilegeScenario[] { + const scenarios: PrivilegeScenario[] = []; + + const isGlobalPrivilege = isGlobalPrivilegeDefinition(privilegeSpec); + + const assignedGlobalFeaturePrivilege = this.getAssignedFeaturePrivilege( + this.globalPrivilege, + featureId + ); + + const assignedFeaturePrivilege = this.getAssignedFeaturePrivilege(privilegeSpec, featureId); + const hasAssignedFeaturePrivilege = + !ignoreAssigned && assignedFeaturePrivilege !== NO_PRIVILEGE_VALUE; + + scenarios.push({ + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + actions: [...this.assignedGlobalBaseActions], + ...this.buildSupercededFields( + hasAssignedFeaturePrivilege, + assignedFeaturePrivilege, + isGlobalPrivilege ? PRIVILEGE_SOURCE.GLOBAL_FEATURE : PRIVILEGE_SOURCE.SPACE_FEATURE + ), + }); + + if (!isGlobalPrivilege || !ignoreAssigned) { + scenarios.push({ + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + actions: this.getFeatureActions(featureId, assignedGlobalFeaturePrivilege), + isDirectlyAssigned: isGlobalPrivilege && hasAssignedFeaturePrivilege, + ...this.buildSupercededFields( + hasAssignedFeaturePrivilege && !isGlobalPrivilege, + assignedFeaturePrivilege, + PRIVILEGE_SOURCE.SPACE_FEATURE + ), + }); + } + + if (isGlobalPrivilege) { + return this.rankScenarios(scenarios); + } + + // Otherwise, this is a space feature privilege + + const includeSpaceBaseScenario = + basePrivilegeExplanation.actualPrivilegeSource === PRIVILEGE_SOURCE.SPACE_BASE || + basePrivilegeExplanation.supersededPrivilegeSource === PRIVILEGE_SOURCE.SPACE_BASE; + + const spaceBasePrivilege = + basePrivilegeExplanation.supersededPrivilege || basePrivilegeExplanation.actualPrivilege; + + if (includeSpaceBaseScenario) { + scenarios.push({ + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: false, + actions: this.getBaseActions(PRIVILEGE_SOURCE.SPACE_BASE, spaceBasePrivilege), + ...this.buildSupercededFields( + hasAssignedFeaturePrivilege, + assignedFeaturePrivilege, + PRIVILEGE_SOURCE.SPACE_FEATURE + ), + }); + } + + if (!ignoreAssigned) { + scenarios.push({ + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + actions: this.getFeatureActions( + featureId, + this.getAssignedFeaturePrivilege(privilegeSpec, featureId) + ), + }); + } + + return this.rankScenarios(scenarios); + } + + private rankScenarios(scenarios: PrivilegeScenario[]): PrivilegeScenario[] { + return scenarios.sort( + (scenario1, scenario2) => scenario1.actualPrivilegeSource - scenario2.actualPrivilegeSource + ); + } + + private getBaseActions(source: PRIVILEGE_SOURCE, privilegeId: string) { + switch (source) { + case PRIVILEGE_SOURCE.GLOBAL_BASE: + return this.assignedGlobalBaseActions; + case PRIVILEGE_SOURCE.SPACE_BASE: + return this.privilegeDefinition.getSpacesPrivileges().getActions(privilegeId); + default: + throw new Error( + `Cannot get base actions for unsupported privilege source ${PRIVILEGE_SOURCE[source]}` + ); + } + } + + private getFeatureActions(featureId: string, privilegeId: string) { + return this.privilegeDefinition.getFeaturePrivileges().getActions(featureId, privilegeId); + } + + private getAssignedFeaturePrivilege(privilegeSpec: KibanaPrivilegeSpec, featureId: string) { + const featureEntry = privilegeSpec.feature[featureId] || []; + return featureEntry[0] || NO_PRIVILEGE_VALUE; + } + + private buildSupercededFields( + isSuperceding: boolean, + supersededPrivilege?: string, + supersededPrivilegeSource?: PRIVILEGE_SOURCE + ) { + if (!isSuperceding) { + return {}; + } + return { + supersededPrivilege, + supersededPrivilegeSource, + }; + } +} diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privilege_calculator.test.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privilege_calculator.test.ts new file mode 100644 index 0000000000000..6cf58c5a69bba --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privilege_calculator.test.ts @@ -0,0 +1,791 @@ +/* + * 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 { PrivilegeDefinition, Role } from '../../../common/model'; +import { NO_PRIVILEGE_VALUE } from '../../views/management/edit_role/lib/constants'; +import { + buildRole, + defaultPrivilegeDefinition, + fullyRestrictedBasePrivileges, + fullyRestrictedFeaturePrivileges, + unrestrictedBasePrivileges, + unrestrictedFeaturePrivileges, +} from './__fixtures__'; +import { + AllowedPrivilege, + PRIVILEGE_SOURCE, + PrivilegeExplanation, +} from './kibana_privilege_calculator_types'; +import { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory'; + +const buildEffectivePrivileges = ( + role: Role, + privilegeDefinition: PrivilegeDefinition = defaultPrivilegeDefinition +) => { + const factory = new KibanaPrivilegeCalculatorFactory(privilegeDefinition); + return factory.getInstance(role); +}; + +const buildExpectedFeaturePrivileges = ( + expectedFeaturePrivileges: PrivilegeExplanation | { [featureId: string]: PrivilegeExplanation } +) => { + if (expectedFeaturePrivileges.hasOwnProperty('actualPrivilege')) { + return { + feature: { + feature1: expectedFeaturePrivileges, + feature2: expectedFeaturePrivileges, + feature3: expectedFeaturePrivileges, + }, + }; + } + + return { + feature: { + ...expectedFeaturePrivileges, + }, + }; +}; + +describe('calculateEffectivePrivileges', () => { + it(`returns an empty array for an empty role`, () => { + const role = buildRole(); + role.kibana = []; + + const effectivePrivileges = buildEffectivePrivileges(role); + const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); + expect(calculatedPrivileges).toHaveLength(0); + }); + + it(`calculates "none" for all privileges when nothing is assigned`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['foo', 'bar'], + base: [], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivileges(role); + const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); + expect(calculatedPrivileges).toEqual([ + { + base: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: true, + }, + ...buildExpectedFeaturePrivileges({ + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }), + }, + ]); + }); + + describe(`with global base privilege of "all"`, () => { + it(`calculates global feature privileges === all`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivileges(role); + const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); + + expect(calculatedPrivileges).toEqual([ + { + base: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + }, + ...buildExpectedFeaturePrivileges({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }), + }, + ]); + }); + + it(`calculates space base and feature privileges === all`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivileges(role); + const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); + + const calculatedSpacePrivileges = calculatedPrivileges[1]; + + expect(calculatedSpacePrivileges).toEqual({ + base: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + ...buildExpectedFeaturePrivileges({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }), + }); + }); + + describe(`and with feature privileges assigned`, () => { + it('returns the base privileges when they are more permissive', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: { + feature1: ['read'], + feature2: ['read'], + feature3: ['read'], + }, + }, + { + spaces: ['foo'], + base: [], + feature: { + feature1: ['read'], + feature2: ['read'], + feature3: ['read'], + }, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivileges(role); + const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); + + expect(calculatedPrivileges).toEqual([ + { + base: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + }, + ...buildExpectedFeaturePrivileges({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + supersededPrivilege: 'read', + supersededPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + }), + }, + { + base: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + ...buildExpectedFeaturePrivileges({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + supersededPrivilege: 'read', + supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + }), + }, + ]); + }); + }); + }); + + describe(`with global base privilege of "read"`, () => { + it(`it calculates space base and feature privileges when none are provided`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivileges(role); + const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); + + expect(calculatedPrivileges).toEqual([ + { + base: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + }, + ...buildExpectedFeaturePrivileges({ + feature1: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + feature2: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + feature3: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + }), + }, + { + base: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + ...buildExpectedFeaturePrivileges({ + feature1: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + feature2: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + feature3: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }, + }), + }, + ]); + }); + + describe('and with feature privileges assigned', () => { + it('returns the feature privileges when they are more permissive', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: { + feature1: ['all'], + feature2: ['all'], + feature3: ['all'], + }, + }, + { + spaces: ['foo'], + base: [], + feature: { + feature1: ['all'], + feature2: ['all'], + feature3: ['all'], + }, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivileges(role); + const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); + + expect(calculatedPrivileges).toEqual([ + { + base: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + }, + ...buildExpectedFeaturePrivileges({ + feature1: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + feature2: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + feature3: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + }), + }, + { + base: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + ...buildExpectedFeaturePrivileges({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }), + }, + ]); + }); + }); + }); + + describe('with both global and space base privileges assigned', () => { + it(`does not override space base of "all" when global base is "read"`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: ['all'], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivileges(role); + const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); + + expect(calculatedPrivileges).toEqual([ + { + base: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + }, + ...buildExpectedFeaturePrivileges({ + feature1: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + feature2: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + feature3: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + }), + }, + { + base: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: true, + }, + ...buildExpectedFeaturePrivileges({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + isDirectlyAssigned: false, + }), + }, + ]); + }); + + it(`calcualtes "all" for space base and space features when superceded by global "all"`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['foo'], + base: ['read'], + feature: {}, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivileges(role); + const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); + + expect(calculatedPrivileges).toEqual([ + { + base: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + }, + ...buildExpectedFeaturePrivileges({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }), + }, + { + base: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + supersededPrivilege: 'read', + supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + }, + ...buildExpectedFeaturePrivileges({ + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }), + }, + ]); + }); + + it(`does not override feature privileges when they are more permissive`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: ['read'], + feature: { + feature1: ['all'], + feature2: ['all'], + feature3: ['all'], + }, + }, + ], + }); + const effectivePrivileges = buildEffectivePrivileges(role); + const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); + + expect(calculatedPrivileges).toEqual([ + { + base: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: true, + }, + ...buildExpectedFeaturePrivileges({ + feature1: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + feature2: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + }, + feature3: { + actualPrivilege: NO_PRIVILEGE_VALUE, + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, + isDirectlyAssigned: true, + }, + }), + }, + { + base: { + actualPrivilege: 'read', + actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, + isDirectlyAssigned: false, + supersededPrivilege: 'read', + supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, + }, + ...buildExpectedFeaturePrivileges({ + feature1: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }, + feature2: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }, + feature3: { + actualPrivilege: 'all', + actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, + isDirectlyAssigned: true, + }, + }), + }, + ]); + }); + }); +}); + +describe('calculateAllowedPrivileges', () => { + it('allows all privileges when none are currently assigned', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: [], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + + const privilegeCalculator = buildEffectivePrivileges(role); + + const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + ...unrestrictedFeaturePrivileges, + }, + { + ...unrestrictedBasePrivileges, + ...unrestrictedFeaturePrivileges, + }, + ]); + }); + + it('allows all global base privileges, but just "all" for everything else when global is set to "all"', () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + + const privilegeCalculator = buildEffectivePrivileges(role); + + const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + ...fullyRestrictedFeaturePrivileges, + }, + { + ...fullyRestrictedBasePrivileges, + ...fullyRestrictedFeaturePrivileges, + }, + ]); + }); + + it(`allows feature privileges to be set to "all" or "read" when global base is "read"`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + + const privilegeCalculator = buildEffectivePrivileges(role); + + const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); + + const expectedFeaturePrivileges = { + feature: { + feature1: { + privileges: ['all', 'read'], + canUnassign: false, + }, + feature2: { + privileges: ['all', 'read'], + canUnassign: false, + }, + feature3: { + privileges: ['all'], + canUnassign: true, // feature 3 has no "read" privilege governed by global "all" + }, + }, + }; + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + ...expectedFeaturePrivileges, + }, + { + base: { + privileges: ['all', 'read'], + canUnassign: false, + }, + ...expectedFeaturePrivileges, + }, + ]); + }); + + it(`allows feature privileges to be set to "all" or "read" when space base is "read"`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: [], + feature: {}, + }, + { + spaces: ['foo'], + base: ['read'], + feature: {}, + }, + ], + }); + + const privilegeCalculator = buildEffectivePrivileges(role); + + const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + ...unrestrictedFeaturePrivileges, + }, + { + base: { + privileges: ['all', 'read'], + canUnassign: true, + }, + feature: { + feature1: { + privileges: ['all', 'read'], + canUnassign: false, + }, + feature2: { + privileges: ['all', 'read'], + canUnassign: false, + }, + feature3: { + privileges: ['all'], + canUnassign: true, // feature 3 has no "read" privilege governed by space "all" + }, + }, + }, + ]); + }); + + it(`allows space base privilege to be set to "all" or "read" when space base is already "all"`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['foo'], + base: ['all'], + feature: {}, + }, + ], + }); + + const privilegeCalculator = buildEffectivePrivileges(role); + + const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + feature: { + feature1: { + privileges: ['all'], + canUnassign: false, + }, + feature2: { + privileges: ['all'], + canUnassign: false, + }, + feature3: { + privileges: ['all'], + canUnassign: false, + }, + }, + }, + ]); + }); + + it(`restricts space feature privileges when global feature privileges are set`, () => { + const role = buildRole({ + spacesPrivileges: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['all'], + feature2: ['read'], + }, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ], + }); + + const privilegeCalculator = buildEffectivePrivileges(role); + + const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); + + expect(result).toEqual([ + { + ...unrestrictedBasePrivileges, + ...unrestrictedFeaturePrivileges, + }, + { + base: { + privileges: ['all', 'read'], + canUnassign: true, + }, + feature: { + feature1: { + privileges: ['all'], + canUnassign: false, + }, + feature2: { + privileges: ['all', 'read'], + canUnassign: false, + }, + feature3: { + privileges: ['all'], + canUnassign: true, // feature 3 has no "read" privilege governed by space "all" + }, + }, + }, + ]); + }); +}); diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privilege_calculator.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privilege_calculator.ts new file mode 100644 index 0000000000000..aad22604c9fa7 --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privilege_calculator.ts @@ -0,0 +1,113 @@ +/* + * 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 _ from 'lodash'; +import { + FeaturePrivilegeSet, + KibanaPrivilegeSpec, + PrivilegeDefinition, + Role, +} from '../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../privilege_utils'; +import { KibanaAllowedPrivilegesCalculator } from './kibana_allowed_privileges_calculator'; +import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator'; +import { KibanaFeaturePrivilegeCalculator } from './kibana_feature_privilege_calculator'; +import { AllowedPrivilege, CalculatedPrivilege } from './kibana_privilege_calculator_types'; + +export class KibanaPrivilegeCalculator { + private allowedPrivilegesCalculator: KibanaAllowedPrivilegesCalculator; + + private effectiveBasePrivilegesCalculator: KibanaBasePrivilegeCalculator; + + private effectiveFeaturePrivilegesCalculator: KibanaFeaturePrivilegeCalculator; + + constructor( + private readonly privilegeDefinition: PrivilegeDefinition, + private readonly role: Role, + public readonly rankedFeaturePrivileges: FeaturePrivilegeSet + ) { + const globalPrivilege = this.locateGlobalPrivilege(role); + + const assignedGlobalBaseActions: string[] = globalPrivilege.base[0] + ? privilegeDefinition.getGlobalPrivileges().getActions(globalPrivilege.base[0]) + : []; + + this.allowedPrivilegesCalculator = new KibanaAllowedPrivilegesCalculator( + privilegeDefinition, + role + ); + + this.effectiveBasePrivilegesCalculator = new KibanaBasePrivilegeCalculator( + privilegeDefinition, + globalPrivilege, + assignedGlobalBaseActions + ); + + this.effectiveFeaturePrivilegesCalculator = new KibanaFeaturePrivilegeCalculator( + privilegeDefinition, + globalPrivilege, + assignedGlobalBaseActions, + rankedFeaturePrivileges + ); + } + + public calculateEffectivePrivileges(ignoreAssigned: boolean = false): CalculatedPrivilege[] { + const { kibana = [] } = this.role; + return kibana.map(privilegeSpec => + this.calculateEffectivePrivilege(privilegeSpec, ignoreAssigned) + ); + } + + public calculateAllowedPrivileges(): AllowedPrivilege[] { + const effectivePrivs = this.calculateEffectivePrivileges(true); + return this.allowedPrivilegesCalculator.calculateAllowedPrivileges(effectivePrivs); + } + + private calculateEffectivePrivilege( + privilegeSpec: KibanaPrivilegeSpec, + ignoreAssigned: boolean + ): CalculatedPrivilege { + const result: CalculatedPrivilege = { + base: this.effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( + privilegeSpec, + ignoreAssigned + ), + feature: {}, + }; + + // If calculations wish to ignoreAssigned, then we still need to know what the real effective base privilege is + // without ignoring assigned, in order to calculate the correct feature privileges. + const effectiveBase = ignoreAssigned + ? this.effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(privilegeSpec, false) + : result.base; + + const allFeaturePrivileges = this.privilegeDefinition.getFeaturePrivileges().getAllPrivileges(); + result.feature = Object.keys(allFeaturePrivileges).reduce((acc, featureId) => { + return { + ...acc, + [featureId]: this.effectiveFeaturePrivilegesCalculator.getMostPermissiveFeaturePrivilege( + privilegeSpec, + effectiveBase, + featureId, + ignoreAssigned + ), + }; + }, {}); + + return result; + } + + private locateGlobalPrivilege(role: Role) { + const spacePrivileges = role.kibana; + return ( + spacePrivileges.find(privileges => isGlobalPrivilegeDefinition(privileges)) || { + spaces: [] as string[], + base: [] as string[], + feature: {}, + } + ); + } +} diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privilege_calculator_types.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privilege_calculator_types.ts new file mode 100644 index 0000000000000..65dd9248c32bd --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privilege_calculator_types.ts @@ -0,0 +1,58 @@ +/* + * 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. + */ + +/** + * Describes the source of a privilege. + */ +export enum PRIVILEGE_SOURCE { + /** Privilege is assigned directly to the entity */ + SPACE_FEATURE = 10, + + /** Privilege is derived from space base privilege */ + SPACE_BASE = 20, + + /** Privilege is derived from global feature privilege */ + GLOBAL_FEATURE = 30, + + /** Privilege is derived from global base privilege */ + GLOBAL_BASE = 40, +} + +export interface PrivilegeExplanation { + actualPrivilege: string; + actualPrivilegeSource: PRIVILEGE_SOURCE; + isDirectlyAssigned: boolean; + supersededPrivilege?: string; + supersededPrivilegeSource?: PRIVILEGE_SOURCE; +} + +export interface CalculatedPrivilege { + base: PrivilegeExplanation; + feature: { + [featureId: string]: PrivilegeExplanation; + }; +} + +export interface PrivilegeScenario { + actualPrivilegeSource: PRIVILEGE_SOURCE; + isDirectlyAssigned: boolean; + supersededPrivilege?: string; + supersededPrivilegeSource?: PRIVILEGE_SOURCE; + actions: string[]; +} + +export interface AllowedPrivilege { + base: { + privileges: string[]; + canUnassign: boolean; + }; + feature: { + [featureId: string]: { + privileges: string[]; + canUnassign: boolean; + }; + }; +} diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts new file mode 100644 index 0000000000000..e719a99562371 --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts @@ -0,0 +1,77 @@ +/* + * 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 _ from 'lodash'; +import { FeaturePrivilegeSet, PrivilegeDefinition, Role } from '../../../common/model'; +import { copyRole } from '../../lib/role_utils'; +import { KibanaPrivilegeCalculator } from './kibana_privilege_calculator'; +import { compareActions } from './privilege_calculator_utils'; + +export class KibanaPrivilegeCalculatorFactory { + /** All feature privileges, sorted from most permissive => least permissive. */ + public readonly rankedFeaturePrivileges: FeaturePrivilegeSet; + + constructor(private readonly privilegeDefinition: PrivilegeDefinition) { + this.rankedFeaturePrivileges = {}; + const featurePrivilegeSet = privilegeDefinition.getFeaturePrivileges().getAllPrivileges(); + + Object.entries(featurePrivilegeSet).forEach(([featureId, privileges]) => { + this.rankedFeaturePrivileges[featureId] = privileges.sort((privilege1, privilege2) => { + const privilege1Actions = privilegeDefinition + .getFeaturePrivileges() + .getActions(featureId, privilege1); + const privilege2Actions = privilegeDefinition + .getFeaturePrivileges() + .getActions(featureId, privilege2); + return compareActions(privilege1Actions, privilege2Actions); + }); + }); + } + + /** + * Creates an KibanaPrivilegeCalculator instance for the specified role. + * @param role + */ + public getInstance(role: Role) { + const roleCopy = copyRole(role); + + this.sortPrivileges(roleCopy); + return new KibanaPrivilegeCalculator( + this.privilegeDefinition, + roleCopy, + this.rankedFeaturePrivileges + ); + } + + private sortPrivileges(role: Role) { + role.kibana.forEach(privilege => { + privilege.base.sort((privilege1, privilege2) => { + const privilege1Actions = this.privilegeDefinition + .getSpacesPrivileges() + .getActions(privilege1); + + const privilege2Actions = this.privilegeDefinition + .getSpacesPrivileges() + .getActions(privilege2); + + return compareActions(privilege1Actions, privilege2Actions); + }); + + Object.entries(privilege.feature).forEach(([featureId, featurePrivs]) => { + featurePrivs.sort((privilege1, privilege2) => { + const privilege1Actions = this.privilegeDefinition + .getFeaturePrivileges() + .getActions(featureId, privilege1); + + const privilege2Actions = this.privilegeDefinition + .getFeaturePrivileges() + .getActions(featureId, privilege2); + + return compareActions(privilege1Actions, privilege2Actions); + }); + }); + }); + } +} diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/privilege_calculator_utils.test.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/privilege_calculator_utils.test.ts new file mode 100644 index 0000000000000..7a70750656f1d --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/privilege_calculator_utils.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { areActionsFullyCovered, compareActions } from './privilege_calculator_utils'; + +describe('#compareActions', () => { + it(`returns -1 when the first action set is more permissive than the second action set`, () => { + const actionSet1 = ['foo:/*', 'bar']; + const actionSet2 = ['foo:/*']; + expect(compareActions(actionSet1, actionSet2)).toEqual(-1); + }); + + it(`returns 1 when the second action set is more permissive than the first action set`, () => { + const actionSet1 = ['foo:/*']; + const actionSet2 = ['foo:/*', 'bar']; + expect(compareActions(actionSet1, actionSet2)).toEqual(1); + }); + + it('works without wildcards', () => { + const actionSet1 = ['foo:/bar', 'foo:/bar/baz', 'login', 'somethingElse']; + const actionSet2 = ['foo:/bar', 'foo:/bar/baz', 'login']; + expect(compareActions(actionSet1, actionSet2)).toEqual(-1); + }); + + it('handles wildcards correctly', () => { + const actionSet1 = ['foo:/bar/*']; + const actionSet2 = ['foo:/bar/bam', 'foo:/bar/baz/*']; + expect(compareActions(actionSet1, actionSet2)).toEqual(-1); + }); + + it('supports ties in a stable-sort order', () => { + const actionSet1 = ['foo:/bar/bam', 'foo:/bar/baz/*']; + const actionSet2 = ['foo:/bar/bam', 'foo:/bar/baz/*']; + expect(compareActions(actionSet1, actionSet2)).toEqual(-1); + }); + + it('does not support actions where one is not a subset of the other', () => { + const actionSet1 = ['foo:/bar/bam', 'foo:/bar/baz/*']; + const actionSet2 = ['bar:/*']; + + // check both directions + expect(() => compareActions(actionSet1, actionSet2)).toThrowErrorMatchingInlineSnapshot( + `"Non-comparable action sets! Expected one set of actions to be a subset of the other!"` + ); + expect(() => compareActions(actionSet2, actionSet1)).toThrowErrorMatchingInlineSnapshot( + `"Non-comparable action sets! Expected one set of actions to be a subset of the other!"` + ); + }); +}); + +describe('#areActionsFullyCovered', () => { + it('returns true for two empty sets', () => { + const actionSet1: string[] = []; + const actionSet2: string[] = []; + expect(areActionsFullyCovered(actionSet1, actionSet2)).toEqual(true); + }); + + it('returns true when the first set fully covers the second set', () => { + const actionSet1: string[] = ['foo:/*', 'bar:/*']; + const actionSet2: string[] = ['foo:/bar', 'bar:/baz']; + + expect(areActionsFullyCovered(actionSet1, actionSet2)).toEqual(true); + }); + + it('returns false when the first set does not fully cover the second set', () => { + const actionSet1: string[] = ['foo:/bar', 'bar:/baz']; + const actionSet2: string[] = ['foo:/*', 'bar:/*']; + + expect(areActionsFullyCovered(actionSet1, actionSet2)).toEqual(false); + }); + + it('returns true for ties', () => { + const actionSet1: string[] = ['foo:/bar', 'bar:/baz']; + const actionSet2: string[] = ['foo:/bar', 'bar:/baz']; + + expect(areActionsFullyCovered(actionSet1, actionSet2)).toEqual(true); + }); + + it('can handle actions where one is not a subset of the other', () => { + const actionSet1 = ['foo:/bar/bam', 'foo:/bar/baz/*']; + const actionSet2 = ['bar:/*']; + + expect(areActionsFullyCovered(actionSet1, actionSet2)).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security/public/lib/kibana_privilege_calculator/privilege_calculator_utils.ts b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/privilege_calculator_utils.ts new file mode 100644 index 0000000000000..e767862c46757 --- /dev/null +++ b/x-pack/plugins/security/public/lib/kibana_privilege_calculator/privilege_calculator_utils.ts @@ -0,0 +1,58 @@ +/* + * 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 _ from 'lodash'; + +/** + * Given two sets of actions, where one set is known to be a subset of the other, this will + * determine which set of actions is most permissive, using standard sorting return values: + * -1: actions1 is most permissive + * 1: actions2 is most permissive + * + * All privileges are hierarchal at this point. + * + * @param actionSet1 + * @param actionSet2 + */ +export function compareActions(actionSet1: string[], actionSet2: string[]) { + if (areActionsFullyCovered(actionSet1, actionSet2)) { + return -1; + } + if (areActionsFullyCovered(actionSet2, actionSet1)) { + return 1; + } + throw new Error( + `Non-comparable action sets! Expected one set of actions to be a subset of the other!` + ); +} +/** + * Given two sets of actions, this will determine if the first set fully covers the second set. + * "fully covers" means that all of the actions granted by the second set are also granted by the first set. + * @param actionSet1 + * @param actionSet2 + */ +export function areActionsFullyCovered(actionSet1: string[], actionSet2: string[]) { + const actionExpressions = actionSet1.map(actionToRegExp); + + const isFullyCovered = actionSet2.every((assigned: string) => + // Does any expression from the first set match this action in the second set? + actionExpressions.some((exp: RegExp) => exp.test(assigned)) + ); + + return isFullyCovered; +} + +function actionToRegExp(action: string) { + // Actions are strings that may or may not end with a wildcard ("*"). + // This will excape all characters in the action string that are not the wildcard character. + // Each wildcard character is then turned into a ".*" before the entire thing is turned into a regexp. + return new RegExp( + action + .split('*') + .map(part => _.escapeRegExp(part)) + .join('.*') + ); +} diff --git a/x-pack/plugins/security/public/lib/privilege_utils.test.ts b/x-pack/plugins/security/public/lib/privilege_utils.test.ts new file mode 100644 index 0000000000000..88ac71cbd7634 --- /dev/null +++ b/x-pack/plugins/security/public/lib/privilege_utils.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { hasAssignedFeaturePrivileges, isGlobalPrivilegeDefinition } from './privilege_utils'; + +describe('isGlobalPrivilegeDefinition', () => { + it('returns true if no spaces are defined', () => { + expect( + // @ts-ignore + isGlobalPrivilegeDefinition({ + base: [], + feature: {}, + }) + ).toEqual(true); + }); + + it('returns true if spaces is an empty array', () => { + expect( + isGlobalPrivilegeDefinition({ + spaces: [], + base: [], + feature: {}, + }) + ).toEqual(true); + }); + + it('returns true if spaces contains "*"', () => { + expect( + isGlobalPrivilegeDefinition({ + spaces: ['*'], + base: [], + feature: {}, + }) + ).toEqual(true); + }); + + it('returns false if spaces does not contain "*"', () => { + expect( + isGlobalPrivilegeDefinition({ + spaces: ['foo', 'bar'], + base: [], + feature: {}, + }) + ).toEqual(false); + }); +}); + +describe('hasAssignedFeaturePrivileges', () => { + it('returns false if no feature privileges are defined', () => { + expect( + hasAssignedFeaturePrivileges({ + spaces: [], + base: [], + feature: {}, + }) + ).toEqual(false); + }); + + it('returns false if feature privileges are defined but not assigned', () => { + expect( + hasAssignedFeaturePrivileges({ + spaces: [], + base: [], + feature: { + foo: [], + }, + }) + ).toEqual(false); + }); + + it('returns true if feature privileges are defined and assigned', () => { + expect( + hasAssignedFeaturePrivileges({ + spaces: [], + base: [], + feature: { + foo: ['all'], + }, + }) + ).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security/public/lib/privilege_utils.ts b/x-pack/plugins/security/public/lib/privilege_utils.ts new file mode 100644 index 0000000000000..fd7194a09ac7d --- /dev/null +++ b/x-pack/plugins/security/public/lib/privilege_utils.ts @@ -0,0 +1,27 @@ +/* + * 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 { KibanaPrivilegeSpec } from '../../common/model'; + +/** + * Determines if the passed privilege spec defines global privileges. + * @param privilegeSpec + */ +export function isGlobalPrivilegeDefinition(privilegeSpec: KibanaPrivilegeSpec): boolean { + if (!privilegeSpec.spaces || privilegeSpec.spaces.length === 0) { + return true; + } + return privilegeSpec.spaces.includes('*'); +} + +/** + * Determines if the passed privilege spec defines feature privileges. + * @param privilegeSpec + */ +export function hasAssignedFeaturePrivileges(privilegeSpec: KibanaPrivilegeSpec): boolean { + const featureKeys = Object.keys(privilegeSpec.feature); + return featureKeys.length > 0 && featureKeys.some(key => privilegeSpec.feature[key].length > 0); +} diff --git a/x-pack/plugins/security/public/lib/role.test.ts b/x-pack/plugins/security/public/lib/role_utils.test.ts similarity index 51% rename from x-pack/plugins/security/public/lib/role.test.ts rename to x-pack/plugins/security/public/lib/role_utils.test.ts index c86b250e034f6..706d74b09c036 100644 --- a/x-pack/plugins/security/public/lib/role.test.ts +++ b/x-pack/plugins/security/public/lib/role_utils.test.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isReservedRole, isRoleEnabled } from './role'; +import { Role } from '../../common/model'; +import { copyRole, isReadOnlyRole, isReservedRole, isRoleEnabled } from './role_utils'; describe('role', () => { describe('isRoleEnabled', () => { @@ -56,4 +57,64 @@ describe('role', () => { expect(isReservedRole(testRole)).toBe(false); }); }); + + describe('isReadOnlyRole', () => { + test('returns true for reserved roles', () => { + const testRole = { + metadata: { + _reserved: true, + }, + }; + expect(isReadOnlyRole(testRole)).toBe(true); + }); + + test('returns true for roles with transform errors', () => { + const testRole = { + _transform_error: ['kibana'], + }; + expect(isReadOnlyRole(testRole)).toBe(true); + }); + + test('returns false for all other roles', () => { + const testRole = {}; + expect(isReadOnlyRole(testRole)).toBe(false); + }); + }); + + describe('copyRole', () => { + it('should perform a deep copy', () => { + const role: Role = { + name: '', + elasticsearch: { + cluster: ['all'], + indices: [{ names: ['index*'], privileges: ['all'] }], + run_as: ['user'], + }, + kibana: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + { + spaces: ['default'], + base: ['foo'], + feature: {}, + }, + { + spaces: ['marketing'], + base: ['read'], + feature: {}, + }, + ], + }; + + const result = copyRole(role); + expect(result).toEqual(role); + + role.elasticsearch.indices[0].names = ['something else']; + + expect(result).not.toEqual(role); + }); + }); }); diff --git a/x-pack/plugins/security/public/lib/role.ts b/x-pack/plugins/security/public/lib/role_utils.ts similarity index 59% rename from x-pack/plugins/security/public/lib/role.ts rename to x-pack/plugins/security/public/lib/role_utils.ts index d6221f7aecb4c..471a3b2ba112f 100644 --- a/x-pack/plugins/security/public/lib/role.ts +++ b/x-pack/plugins/security/public/lib/role_utils.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get } from 'lodash'; -import { Role } from '../../common/model/role'; +import { cloneDeep, get } from 'lodash'; +import { Role } from '../../common/model'; /** * Returns whether given role is enabled or not @@ -25,3 +25,21 @@ export function isRoleEnabled(role: Partial) { export function isReservedRole(role: Partial) { return get(role, 'metadata._reserved', false); } + +/** + * Returns whether given role is editable through the UI or not. + * + * @param role the Role as returned by roles API + */ +export function isReadOnlyRole(role: Partial): boolean { + return isReservedRole(role) || !!(role._transform_error && role._transform_error.length > 0); +} + +/** + * Returns a deep copy of the role. + * + * @param role the Role to copy. + */ +export function copyRole(role: Role) { + return cloneDeep(role); +} diff --git a/x-pack/plugins/security/public/lib/transform_role_for_save.test.ts b/x-pack/plugins/security/public/lib/transform_role_for_save.test.ts new file mode 100644 index 0000000000000..1ea19f2637305 --- /dev/null +++ b/x-pack/plugins/security/public/lib/transform_role_for_save.test.ts @@ -0,0 +1,484 @@ +/* + * 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 { Role } from '../../common/model'; +import { transformRoleForSave } from './transform_role_for_save'; + +describe('transformRoleForSave', () => { + describe('spaces disabled', () => { + it('removes placeholder index privileges', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: [], privileges: [] }], + run_as: [], + }, + kibana: [], + }; + + const result = transformRoleForSave(role, false); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }); + }); + + it('removes placeholder query entries', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: '' }], + run_as: [], + }, + kibana: [], + }; + + const result = transformRoleForSave(role, false); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'] }], + run_as: [], + }, + kibana: [], + }); + }); + + it('removes transient fields not required for save', () => { + const role: Role = { + name: 'my role', + transient_metadata: { + foo: 'bar', + }, + _transform_error: ['kibana'], + metadata: { + someOtherMetadata: true, + }, + _unrecognized_applications: ['foo'], + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }; + + const result = transformRoleForSave(role, false); + + expect(result).toEqual({ + metadata: { + someOtherMetadata: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }); + }); + + it('does not remove actual query entries', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], + run_as: [], + }, + kibana: [], + }; + + const result = transformRoleForSave(role, false); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], + run_as: [], + }, + kibana: [], + }); + }); + + it('should remove feature privileges if a corresponding base privilege is defined', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: ['all'], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }; + + const result = transformRoleForSave(role, false); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: ['all'], + feature: {}, + }, + ], + }); + }); + + it('should not remove feature privileges if a corresponding base privilege is not defined', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }; + + const result = transformRoleForSave(role, false); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }); + }); + + it('should remove space privileges', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }; + + const result = transformRoleForSave(role, false); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }); + }); + }); + + describe('spaces enabled', () => { + it('removes placeholder index privileges', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: [], privileges: [] }], + run_as: [], + }, + kibana: [], + }; + + const result = transformRoleForSave(role, true); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }); + }); + + it('removes placeholder query entries', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: '' }], + run_as: [], + }, + kibana: [], + }; + + const result = transformRoleForSave(role, true); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'] }], + run_as: [], + }, + kibana: [], + }); + }); + + it('removes transient fields not required for save', () => { + const role: Role = { + name: 'my role', + transient_metadata: { + foo: 'bar', + }, + _transform_error: ['kibana'], + metadata: { + someOtherMetadata: true, + }, + _unrecognized_applications: ['foo'], + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }; + + const result = transformRoleForSave(role, true); + + expect(result).toEqual({ + metadata: { + someOtherMetadata: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }); + }); + + it('does not remove actual query entries', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], + run_as: [], + }, + kibana: [], + }; + + const result = transformRoleForSave(role, true); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], + run_as: [], + }, + kibana: [], + }); + }); + + it('should remove feature privileges if a corresponding base privilege is defined', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['foo'], + base: ['all'], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }; + + const result = transformRoleForSave(role, true); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['foo'], + base: ['all'], + feature: {}, + }, + ], + }); + }); + + it('should not remove feature privileges if a corresponding base privilege is not defined', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['foo'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }; + + const result = transformRoleForSave(role, true); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['foo'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }); + }); + + it('should not remove space privileges', () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }; + + const result = transformRoleForSave(role, true); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/lib/transform_role_for_save.ts b/x-pack/plugins/security/public/lib/transform_role_for_save.ts new file mode 100644 index 0000000000000..3b516df0a23b3 --- /dev/null +++ b/x-pack/plugins/security/public/lib/transform_role_for_save.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. + */ + +import { IndexPrivilege, Role } from '../../common/model'; +import { isGlobalPrivilegeDefinition } from './privilege_utils'; + +export function transformRoleForSave(role: Role, spacesEnabled: boolean) { + // Remove any placeholder index privileges + role.elasticsearch.indices = role.elasticsearch.indices.filter( + indexPrivilege => !isPlaceholderPrivilege(indexPrivilege) + ); + + // Remove any placeholder query entries + role.elasticsearch.indices.forEach(index => index.query || delete index.query); + + // If spaces are disabled, then do not persist any space privileges + if (!spacesEnabled) { + role.kibana = role.kibana.filter(isGlobalPrivilegeDefinition); + } + + role.kibana.forEach(kibanaPrivilege => { + // If a base privilege is defined, then do not persist feature privileges + if (kibanaPrivilege.base.length > 0) { + kibanaPrivilege.feature = {}; + } + }); + + delete role.name; + delete role.transient_metadata; + delete role._unrecognized_applications; + delete role._transform_error; + + return role; +} + +function isPlaceholderPrivilege(indexPrivilege: IndexPrivilege) { + return indexPrivilege.names.length === 0; +} diff --git a/x-pack/plugins/security/public/objects/lib/roles.ts b/x-pack/plugins/security/public/objects/lib/roles.ts index 2551d7eabc4e7..e33cbe4c6c031 100644 --- a/x-pack/plugins/security/public/objects/lib/roles.ts +++ b/x-pack/plugins/security/public/objects/lib/roles.ts @@ -3,14 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { omit } from 'lodash'; import chrome from 'ui/chrome'; -import { Role } from '../../../common/model/role'; +import { Role } from '../../../common/model'; +import { copyRole } from '../../lib/role_utils'; +import { transformRoleForSave } from '../../lib/transform_role_for_save'; const apiBase = chrome.addBasePath(`/api/security/role`); -export async function saveRole($http: any, role: Role) { - const data = omit(role, 'name', 'transient_metadata', '_unrecognized_applications'); +export async function saveRole($http: any, role: Role, spacesEnabled: boolean) { + const data = transformRoleForSave(copyRole(role), spacesEnabled); + return await $http.put(`${apiBase}/${role.name}`, data); } diff --git a/x-pack/plugins/security/public/views/_index.scss b/x-pack/plugins/security/public/views/_index.scss new file mode 100644 index 0000000000000..fa95d44f637e4 --- /dev/null +++ b/x-pack/plugins/security/public/views/_index.scss @@ -0,0 +1,8 @@ +// Public views +@import './logged_out/index'; + +// Login styles +@import './login/index'; + +// Management styles +@import './management/index'; diff --git a/x-pack/plugins/security/public/views/management/_index.scss b/x-pack/plugins/security/public/views/management/_index.scss new file mode 100644 index 0000000000000..d1064f5895739 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/_index.scss @@ -0,0 +1,2 @@ +@import './change_password_form/index'; +@import './edit_role/index'; diff --git a/x-pack/plugins/security/public/views/management/change_password_form/_change_password_form.scss b/x-pack/plugins/security/public/views/management/change_password_form/_change_password_form.scss new file mode 100644 index 0000000000000..98331c2070a31 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/change_password_form/_change_password_form.scss @@ -0,0 +1,17 @@ +.secChangePasswordForm__panel { + max-width: $secFormWidth; +} + +.secChangePasswordForm__subLabel { + margin-bottom: $euiSizeS; +} + +.secChangePasswordForm__footer { + display: flex; + justify-content: flex-start; + align-items: center; + + .kuiButton + .kuiButton { + margin-left: $euiSizeS; + } +} diff --git a/x-pack/plugins/security/public/views/management/change_password_form/_index.scss b/x-pack/plugins/security/public/views/management/change_password_form/_index.scss new file mode 100644 index 0000000000000..a6058b5ddebbf --- /dev/null +++ b/x-pack/plugins/security/public/views/management/change_password_form/_index.scss @@ -0,0 +1 @@ +@import './change_password_form'; diff --git a/x-pack/plugins/security/public/views/management/change_password_form/change_password_form.html b/x-pack/plugins/security/public/views/management/change_password_form/change_password_form.html index f5f717f27e9c5..92fb95861a6f8 100644 --- a/x-pack/plugins/security/public/views/management/change_password_form/change_password_form.html +++ b/x-pack/plugins/security/public/views/management/change_password_form/change_password_form.html @@ -117,7 +117,7 @@
-