From b82cc6ed4a1c6c08ebba856b9bba71fafbf23789 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 24 Mar 2020 11:12:49 -0400 Subject: [PATCH] Support for sub-feature privileges (#60563) * initial server-side support for sub-feature privileges (#57507) * initial server-side support for sub-feature privileges * start addressing PR feedback * renaming interfaces * move privilege id collision check to security plugin * additional testing * change featurePrivilegeIterator import location * fix link assertions following rebase from master * Initial UI support for sub-feature privileges (#59198) * Initial UI support for sub-feature privileges * Address PR feedback * display deleted spaces correctly in the privilege summary * additional testing * update snapshot * Enables sub-feature privileges for gold+ licenses (#59750) * enables sub-feature privileges for gold+ licenses * Address PR feedback * address platform review feedback --- .../np_ready/dashboard_app_controller.tsx | 3 +- x-pack/legacy/plugins/apm/index.ts | 3 + x-pack/legacy/plugins/graph/index.ts | 5 + x-pack/legacy/plugins/maps/server/plugin.js | 5 + x-pack/legacy/plugins/siem/server/plugin.ts | 5 + .../plugins/xpack_main/server/xpack_main.d.ts | 4 +- x-pack/plugins/canvas/server/plugin.ts | 5 + x-pack/plugins/endpoint/server/plugin.ts | 2 + x-pack/plugins/features/common/feature.ts | 88 +- .../common/feature_kibana_privileges.ts | 2 - x-pack/plugins/features/common/index.ts | 9 +- x-pack/plugins/features/common/sub_feature.ts | 87 + x-pack/plugins/features/kibana.json | 2 +- .../public/features_api_client.test.ts | 44 + .../features/public/features_api_client.ts | 17 + x-pack/plugins/features/public/index.ts | 16 +- x-pack/plugins/features/public/mocks.ts | 17 + x-pack/plugins/features/public/plugin.test.ts | 53 + x-pack/plugins/features/public/plugin.ts | 27 + .../__snapshots__/oss_features.test.ts.snap | 458 +++++ .../features/server/feature_registry.test.ts | 517 +++++- .../features/server/feature_registry.ts | 25 +- .../plugins/features/server/feature_schema.ts | 211 ++- x-pack/plugins/features/server/index.ts | 2 +- .../features/server/oss_features.test.ts | 15 + .../plugins/features/server/oss_features.ts | 159 +- x-pack/plugins/features/server/plugin.ts | 4 +- .../features/server/routes/index.test.ts | 196 ++- .../plugins/features/server/routes/index.ts | 20 +- .../ui_capabilities_for_features.test.ts | 173 +- .../server/ui_capabilities_for_features.ts | 9 +- x-pack/plugins/infra/server/features.ts | 10 + .../plugins/ingest_manager/server/plugin.ts | 2 + x-pack/plugins/ml/server/plugin.ts | 5 +- x-pack/plugins/monitoring/server/plugin.ts | 4 +- .../common/licensing/license_features.ts | 5 + .../common/licensing/license_service.test.ts | 14 +- .../common/licensing/license_service.ts | 7 +- x-pack/plugins/security/common/model/index.ts | 3 +- .../kibana_privileges/feature_privileges.ts | 36 - .../kibana_privileges/global_privileges.ts | 17 - .../kibana_privileges/kibana_privileges.ts | 26 - .../kibana_privileges/spaces_privileges.ts | 17 - .../roles/__fixtures__/kibana_features.ts | 208 +++ .../roles/__fixtures__/kibana_privileges.ts | 41 + .../roles/edit_role/edit_role_page.test.tsx | 122 +- .../roles/edit_role/edit_role_page.tsx | 30 +- .../roles/edit_role/privilege_utils.test.ts | 38 +- .../roles/edit_role/privilege_utils.ts | 9 - .../kibana_privileges_region.test.tsx.snap | 26 +- .../feature_table/__fixtures__/index.ts | 76 + .../__snapshots__/feature_table.test.tsx.snap | 39 - .../feature_table/change_all_privileges.tsx | 30 +- .../feature_table/feature_table.test.tsx | 868 ++++++++-- .../kibana/feature_table/feature_table.tsx | 402 +++-- .../feature_table_expanded_row.test.tsx | 199 +++ .../feature_table_expanded_row.tsx | 95 ++ .../feature_table/sub_feature_form.test.tsx | 237 +++ .../kibana/feature_table/sub_feature_form.tsx | 134 ++ .../feature_table_cell.test.tsx | 60 + .../feature_table_cell/feature_table_cell.tsx | 41 + .../index.ts | 2 +- .../__fixtures__/build_role.ts | 38 - .../__fixtures__/common_allowed_privileges.ts | 60 - .../default_privilege_definition.ts | 43 - ...bana_allowed_privileges_calculator.test.ts | 313 ---- .../kibana_allowed_privileges_calculator.ts | 155 -- .../kibana_base_privilege_calculator.test.ts | 321 ---- .../kibana_base_privilege_calculator.ts | 98 -- ...ibana_feature_privilege_calculator.test.ts | 959 ----------- .../kibana_feature_privilege_calculator.ts | 209 --- .../kibana_privilege_calculator.test.ts | 940 ----------- .../kibana_privilege_calculator.ts | 113 -- .../kibana_privilege_calculator_types.ts | 63 - .../kibana_privileges_calculator_factory.ts | 81 - .../kibana/kibana_privileges_region.test.tsx | 21 +- .../kibana/kibana_privileges_region.tsx | 17 +- .../index.ts | 3 +- .../privilege_form_calculator.test.ts | 833 ++++++++++ .../privilege_form_calculator.ts | 303 ++++ .../privilege_summary/__fixtures__/index.ts | 129 ++ .../kibana/privilege_summary}/index.ts | 2 +- .../privilege_summary.test.tsx | 82 + .../privilege_summary/privilege_summary.tsx | 73 + .../privilege_summary_calculator.test.ts | 338 ++++ .../privilege_summary_calculator.ts | 109 ++ .../privilege_summary_expanded_row.tsx | 131 ++ .../privilege_summary_table.test.tsx | 922 +++++++++++ .../privilege_summary_table.tsx | 174 ++ .../space_column_header.test.tsx | 123 ++ .../privilege_summary/space_column_header.tsx | 78 + .../simple_privilege_section.test.tsx.snap | 266 +-- .../simple_privilege_section.test.tsx | 82 +- .../simple_privilege_section.tsx | 323 ++-- .../__fixtures__/raw_kibana_privileges.ts | 38 - .../privilege_display.test.tsx.snap | 118 -- .../privilege_space_form.test.tsx.snap | 497 ------ .../privilege_display.test.tsx | 42 +- .../privilege_display.tsx | 133 +- .../privilege_matrix.test.tsx | 128 -- .../privilege_matrix.tsx | 342 ---- .../privilege_space_form.test.tsx | 454 +++-- .../privilege_space_form.tsx | 249 ++- .../privilege_space_table.test.tsx | 283 +++- .../privilege_space_table.tsx | 182 +- .../space_aware_privilege_section.test.tsx | 43 +- .../space_aware_privilege_section.tsx | 92 +- .../space_selector.tsx | 9 +- .../spaces_popover_list.test.tsx | 112 ++ .../spaces_popover_list.tsx | 19 +- .../public/management/roles/model/index.ts | 14 + .../roles/model/kibana_privilege.ts | 31 + .../roles/model/kibana_privileges.test.ts | 144 ++ .../roles/model/kibana_privileges.ts | 86 + .../roles/model/primary_feature_privilege.ts | 29 + .../roles/model/privilege_collection.test.ts | 66 + .../roles/model/privilege_collection.ts | 33 + .../management/roles/model/secured_feature.ts | 77 + .../roles/model/secured_sub_feature.ts | 41 + .../roles/model/sub_feature_privilege.ts | 21 + .../model/sub_feature_privilege_group.ts | 25 + .../roles/roles_management_app.test.tsx | 7 +- .../management/roles/roles_management_app.tsx | 3 +- .../plugins/security/public/plugin.test.tsx | 4 + x-pack/plugins/security/public/plugin.tsx | 2 + .../server/authorization/actions/actions.ts | 7 - .../server/authorization/actions/api.test.ts | 7 - .../server/authorization/actions/api.ts | 4 - .../server/authorization/actions/app.test.ts | 7 - .../server/authorization/actions/app.ts | 4 - .../actions/saved_object.test.ts | 7 - .../authorization/actions/saved_object.ts | 4 - .../server/authorization/actions/ui.test.ts | 28 - .../server/authorization/actions/ui.ts | 16 - .../disable_ui_capabilities.test.ts | 55 +- .../server/authorization/index.test.ts | 2 +- .../security/server/authorization/index.ts | 5 +- .../feature_privilege_builder/app.ts | 2 +- .../feature_privilege_builder/catalogue.ts | 2 +- .../feature_privilege_builder/management.ts | 2 +- .../feature_privilege_iterator.test.ts | 891 ++++++++++ .../feature_privilege_iterator.ts | 83 + .../feature_privilege_iterator}/index.ts | 5 +- .../sub_feature_privilege_iterator.ts | 18 + .../server/authorization/privileges/index.ts | 1 + .../privileges/privileges.test.ts | 1473 ++++++++++++----- .../authorization/privileges/privileges.ts | 104 +- .../validate_feature_privileges.test.ts | 218 ++- .../validate_feature_privileges.ts | 34 +- x-pack/plugins/security/server/plugin.test.ts | 1 - .../server/routes/views/login.test.ts | 1 + .../enabled_features.test.tsx.snap | 4 +- .../enabled_features.test.tsx | 8 +- .../enabled_features/enabled_features.tsx | 4 +- .../enabled_features/feature_table.tsx | 9 +- .../edit_space/manage_space_page.test.tsx | 66 +- .../edit_space/manage_space_page.tsx | 30 +- .../public/management/lib/feature_utils.ts | 4 +- .../management/management_service.test.ts | 22 +- .../spaces_grid/spaces_grid_page.tsx | 19 +- .../spaces_grid/spaces_grid_pages.test.tsx | 85 +- .../management/spaces_management_app.test.tsx | 13 +- .../management/spaces_management_app.tsx | 11 +- x-pack/plugins/spaces/public/plugin.test.ts | 3 +- x-pack/plugins/spaces/public/plugin.tsx | 4 +- .../capabilities_switcher.test.ts | 5 +- .../translations/translations/ja-JP.json | 15 - .../translations/translations/zh-CN.json | 15 - x-pack/plugins/uptime/server/kibana.index.ts | 5 + .../common/fixtures/plugins/actions/index.ts | 2 + .../common/fixtures/plugins/alerts/index.ts | 2 + .../api_integration/apis/security/index.js | 3 + .../apis/security/privileges.ts | 82 +- .../apis/security/privileges_basic.ts | 75 + .../apis/security/security_basic.ts | 24 + .../api_integration/config_security_basic.js | 2 +- .../feature_controls/dashboard_security.ts | 109 ++ .../feature_controls/discover_security.ts | 91 + .../feature_controls/visualize_security.ts | 107 ++ .../fixtures/plugins/foo_plugin/index.js | 4 + 180 files changed, 12447 insertions(+), 7069 deletions(-) create mode 100644 x-pack/plugins/features/common/sub_feature.ts create mode 100644 x-pack/plugins/features/public/features_api_client.test.ts create mode 100644 x-pack/plugins/features/public/features_api_client.ts create mode 100644 x-pack/plugins/features/public/mocks.ts create mode 100644 x-pack/plugins/features/public/plugin.test.ts create mode 100644 x-pack/plugins/features/public/plugin.ts create mode 100644 x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap delete mode 100644 x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts delete mode 100644 x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts delete mode 100644 x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts delete mode 100644 x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts create mode 100644 x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts create mode 100644 x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__fixtures__/index.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx rename x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/{space_aware_privilege_section/__fixtures__ => feature_table_cell}/index.ts (79%) delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts rename x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/{kibana_privilege_calculator => privilege_form_calculator}/index.ts (62%) create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts rename x-pack/plugins/security/{common/model/kibana_privileges => public/management/roles/edit_role/privileges/kibana/privilege_summary}/index.ts (81%) create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx delete mode 100644 x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx create mode 100644 x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.test.tsx create mode 100644 x-pack/plugins/security/public/management/roles/model/index.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/kibana_privilege.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/privilege_collection.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/secured_feature.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts create mode 100644 x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts create mode 100644 x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts create mode 100644 x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts rename x-pack/plugins/security/{public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__ => server/authorization/privileges/feature_privilege_iterator}/index.ts (57%) create mode 100644 x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts create mode 100644 x-pack/test/api_integration/apis/security/privileges_basic.ts create mode 100644 x-pack/test/api_integration/apis/security/security_basic.ts diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx index f1e1f20de1ce66..0c6686c9933716 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx @@ -890,7 +890,8 @@ export class DashboardAppController { share.toggleShareContextMenu({ anchorElement, allowEmbed: true, - allowShortUrl: !dashboardConfig.getHideWriteControls(), + allowShortUrl: + !dashboardConfig.getHideWriteControls() || dashboardCapabilities.createShortUrl, shareableUrl: unhashUrl(window.location.href), objectId: dash.id, objectType: 'dashboard', diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 502e910caae519..d1f7ce325d23ed 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -96,6 +96,7 @@ export const apm: LegacyPluginInitializer = kibana => { name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', { defaultMessage: 'APM' }), + order: 900, icon: 'apmApp', navLinkId: 'apm', app: ['apm', 'kibana'], @@ -103,6 +104,7 @@ export const apm: LegacyPluginInitializer = kibana => { // see x-pack/plugins/features/common/feature_kibana_privileges.ts privileges: { all: { + app: ['apm', 'kibana'], api: ['apm', 'apm_write', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { @@ -121,6 +123,7 @@ export const apm: LegacyPluginInitializer = kibana => { ] }, read: { + app: ['apm', 'kibana'], api: ['apm', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { diff --git a/x-pack/legacy/plugins/graph/index.ts b/x-pack/legacy/plugins/graph/index.ts index 5122796335e457..53d32a836cfa17 100644 --- a/x-pack/legacy/plugins/graph/index.ts +++ b/x-pack/legacy/plugins/graph/index.ts @@ -37,6 +37,7 @@ export const graph: LegacyPluginInitializer = kibana => { name: i18n.translate('xpack.graph.featureRegistry.graphFeatureName', { defaultMessage: 'Graph', }), + order: 1200, icon: 'graphApp', navLinkId: 'graph', app: ['graph', 'kibana'], @@ -44,6 +45,8 @@ export const graph: LegacyPluginInitializer = kibana => { validLicenses: ['platinum', 'enterprise', 'trial'], privileges: { all: { + app: ['graph', 'kibana'], + catalogue: ['graph'], savedObject: { all: ['graph-workspace'], read: ['index-pattern'], @@ -51,6 +54,8 @@ export const graph: LegacyPluginInitializer = kibana => { ui: ['save', 'delete'], }, read: { + app: ['graph', 'kibana'], + catalogue: ['graph'], savedObject: { all: [], read: ['index-pattern', 'graph-workspace'], diff --git a/x-pack/legacy/plugins/maps/server/plugin.js b/x-pack/legacy/plugins/maps/server/plugin.js index 02e38ff54b300a..5b52a3eba2f23c 100644 --- a/x-pack/legacy/plugins/maps/server/plugin.js +++ b/x-pack/legacy/plugins/maps/server/plugin.js @@ -23,12 +23,15 @@ export class MapPlugin { name: i18n.translate('xpack.maps.featureRegistry.mapsFeatureName', { defaultMessage: 'Maps', }), + order: 600, icon: APP_ICON, navLinkId: APP_ID, app: [APP_ID, 'kibana'], catalogue: [APP_ID], privileges: { all: { + app: [APP_ID, 'kibana'], + catalogue: [APP_ID], savedObject: { all: [MAP_SAVED_OBJECT_TYPE, 'query'], read: ['index-pattern'], @@ -36,6 +39,8 @@ export class MapPlugin { ui: ['save', 'show', 'saveQuery'], }, read: { + app: [APP_ID, 'kibana'], + catalogue: [APP_ID], savedObject: { all: [], read: [MAP_SAVED_OBJECT_TYPE, 'index-pattern', 'query'], diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index 7008872a6f3cd3..d785de32eab7ee 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -97,12 +97,15 @@ export class Plugin { name: i18n.translate('xpack.siem.featureRegistry.linkSiemTitle', { defaultMessage: 'SIEM', }), + order: 1100, icon: 'securityAnalyticsApp', navLinkId: 'siem', app: ['siem', 'kibana'], catalogue: ['siem'], privileges: { all: { + app: ['siem', 'kibana'], + catalogue: ['siem'], api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], savedObject: { all: [ @@ -128,6 +131,8 @@ export class Plugin { ], }, read: { + app: ['siem', 'kibana'], + catalogue: ['siem'], api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], savedObject: { all: ['alert', 'action', 'action_task_params'], diff --git a/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts b/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts index a9abc733775d22..7b5dc19760627d 100644 --- a/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts +++ b/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts @@ -5,12 +5,12 @@ */ import KbnServer from 'src/legacy/server/kbn_server'; -import { Feature, FeatureWithAllOrReadPrivileges } from '../../../../plugins/features/server'; +import { Feature, FeatureConfig } from '../../../../plugins/features/server'; import { XPackInfo, XPackInfoOptions } from './lib/xpack_info'; export { XPackFeature } from './lib/xpack_info'; export interface XPackMainPlugin { info: XPackInfo; getFeatures(): Feature[]; - registerFeature(feature: FeatureWithAllOrReadPrivileges): void; + registerFeature(feature: FeatureConfig): void; } diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index bfda7ef5885bc3..0325de9cf29e2f 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -32,12 +32,15 @@ export class CanvasPlugin implements Plugin { plugins.features.registerFeature({ id: 'canvas', name: 'Canvas', + order: 400, icon: 'canvasApp', navLinkId: 'canvas', app: ['canvas', 'kibana'], catalogue: ['canvas'], privileges: { all: { + app: ['canvas', 'kibana'], + catalogue: ['canvas'], savedObject: { all: ['canvas-workpad', 'canvas-element'], read: ['index-pattern'], @@ -45,6 +48,8 @@ export class CanvasPlugin implements Plugin { ui: ['save', 'show'], }, read: { + app: ['canvas', 'kibana'], + catalogue: ['canvas'], savedObject: { all: [], read: ['index-pattern', 'canvas-workpad', 'canvas-element'], diff --git a/x-pack/plugins/endpoint/server/plugin.ts b/x-pack/plugins/endpoint/server/plugin.ts index aef85f39e0382d..4b4afd8088744d 100644 --- a/x-pack/plugins/endpoint/server/plugin.ts +++ b/x-pack/plugins/endpoint/server/plugin.ts @@ -43,6 +43,7 @@ export class EndpointPlugin app: ['endpoint', 'kibana'], privileges: { all: { + app: ['endpoint', 'kibana'], api: ['resolver'], savedObject: { all: [], @@ -51,6 +52,7 @@ export class EndpointPlugin ui: ['save'], }, read: { + app: ['endpoint', 'kibana'], api: [], savedObject: { all: [], diff --git a/x-pack/plugins/features/common/feature.ts b/x-pack/plugins/features/common/feature.ts index 748076b95ad77f..82fcc33f5c8ceb 100644 --- a/x-pack/plugins/features/common/feature.ts +++ b/x-pack/plugins/features/common/feature.ts @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FeatureKibanaPrivileges, FeatureKibanaPrivilegesSet } from './feature_kibana_privileges'; +import { RecursiveReadonly } from '@kbn/utility-types'; +import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; +import { SubFeatureConfig, SubFeature } from './sub_feature'; /** * Interface for registering a feature. * Feature registration allows plugins to hide their applications with spaces, * and secure access when configured for security. */ -export interface Feature< - TPrivileges extends Partial = FeatureKibanaPrivilegesSet -> { +export interface FeatureConfig { /** * Unique identifier for this feature. * This identifier is also used when generating UI Capabilities. @@ -28,6 +28,11 @@ export interface Feature< */ name: string; + /** + * An ordinal used to sort features relative to one another for display. + */ + order?: number; + /** * Whether or not this feature should be excluded from the base privileges. * This is primarily helpful when migrating applications with a "legacy" privileges model @@ -98,7 +103,15 @@ export interface Feature< * ``` * @see FeatureKibanaPrivileges */ - privileges: TPrivileges; + privileges: { + all: FeatureKibanaPrivileges; + read: FeatureKibanaPrivileges; + } | null; + + /** + * Optional sub-feature privilege definitions. This can only be specified if `privileges` are are also defined. + */ + subFeatures?: SubFeatureConfig[]; /** * Optional message to display on the Role Management screen when configuring permissions for this feature. @@ -114,7 +127,64 @@ export interface Feature< }; } -export type FeatureWithAllOrReadPrivileges = Feature<{ - all?: FeatureKibanaPrivileges; - read?: FeatureKibanaPrivileges; -}>; +export class Feature { + public readonly subFeatures: SubFeature[]; + + constructor(protected readonly config: RecursiveReadonly) { + this.subFeatures = (config.subFeatures ?? []).map( + subFeatureConfig => new SubFeature(subFeatureConfig) + ); + } + + public get id() { + return this.config.id; + } + + public get name() { + return this.config.name; + } + + public get order() { + return this.config.order; + } + + public get navLinkId() { + return this.config.navLinkId; + } + + public get app() { + return this.config.app; + } + + public get catalogue() { + return this.config.catalogue; + } + + public get management() { + return this.config.management; + } + + public get icon() { + return this.config.icon; + } + + public get validLicenses() { + return this.config.validLicenses; + } + + public get privileges() { + return this.config.privileges; + } + + public get excludeFromBasePrivileges() { + return this.config.excludeFromBasePrivileges ?? false; + } + + public get reserved() { + return this.config.reserved; + } + + public toRaw() { + return { ...this.config } as FeatureConfig; + } +} diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index 1d14f3728282cd..768c8c6ae10880 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -123,5 +123,3 @@ export interface FeatureKibanaPrivileges { */ ui: string[]; } - -export type FeatureKibanaPrivilegesSet = Record; diff --git a/x-pack/plugins/features/common/index.ts b/x-pack/plugins/features/common/index.ts index 6111d7d25a61b9..e359efbda20d29 100644 --- a/x-pack/plugins/features/common/index.ts +++ b/x-pack/plugins/features/common/index.ts @@ -5,4 +5,11 @@ */ export { FeatureKibanaPrivileges } from './feature_kibana_privileges'; -export * from './feature'; +export { Feature, FeatureConfig } from './feature'; +export { + SubFeature, + SubFeatureConfig, + SubFeaturePrivilegeConfig, + SubFeaturePrivilegeGroupConfig, + SubFeaturePrivilegeGroupType, +} from './sub_feature'; diff --git a/x-pack/plugins/features/common/sub_feature.ts b/x-pack/plugins/features/common/sub_feature.ts new file mode 100644 index 00000000000000..121bb8514c8a29 --- /dev/null +++ b/x-pack/plugins/features/common/sub_feature.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RecursiveReadonly } from '@kbn/utility-types'; +import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; + +/** + * Configuration for a sub-feature. + */ +export interface SubFeatureConfig { + /** Display name for this sub-feature */ + name: string; + + /** Collection of privilege groups */ + privilegeGroups: SubFeaturePrivilegeGroupConfig[]; +} + +/** + * The type of privilege group. + * - `mutually_exclusive`:: + * Users will be able to select at most one privilege within this group. + * Privileges must be specified in descending order of permissiveness (e.g. `All`, `Read`, not `Read`, `All) + * - `independent`:: + * Users will be able to select any combination of privileges within this group. + */ +export type SubFeaturePrivilegeGroupType = 'mutually_exclusive' | 'independent'; + +/** + * Configuration for a sub-feature privilege group. + */ +export interface SubFeaturePrivilegeGroupConfig { + /** + * The type of privilege group. + * - `mutually_exclusive`:: + * Users will be able to select at most one privilege within this group. + * Privileges must be specified in descending order of permissiveness (e.g. `All`, `Read`, not `Read`, `All) + * - `independent`:: + * Users will be able to select any combination of privileges within this group. + */ + groupType: SubFeaturePrivilegeGroupType; + + /** + * The privileges which belong to this group. + */ + privileges: SubFeaturePrivilegeConfig[]; +} + +/** + * Configuration for a sub-feature privilege. + */ +export interface SubFeaturePrivilegeConfig + extends Omit { + /** + * Identifier for this privilege. Must be unique across all other privileges within a feature. + */ + id: string; + + /** + * The display name for this privilege. + */ + name: string; + + /** + * Denotes which Primary Feature Privilege this sub-feature privilege should be included in. + * `read` is also included in `all` automatically. + */ + includeIn: 'all' | 'read' | 'none'; +} + +export class SubFeature { + constructor(protected readonly config: RecursiveReadonly) {} + + public get name() { + return this.config.name; + } + + public get privilegeGroups() { + return this.config.privilegeGroups; + } + + public toRaw() { + return { ...this.config }; + } +} diff --git a/x-pack/plugins/features/kibana.json b/x-pack/plugins/features/kibana.json index 553e920f0e720d..e38d7be8929043 100644 --- a/x-pack/plugins/features/kibana.json +++ b/x-pack/plugins/features/kibana.json @@ -4,5 +4,5 @@ "kibanaVersion": "kibana", "optionalPlugins": ["timelion"], "server": true, - "ui": false + "ui": true } diff --git a/x-pack/plugins/features/public/features_api_client.test.ts b/x-pack/plugins/features/public/features_api_client.test.ts new file mode 100644 index 00000000000000..e3a25ad57425c8 --- /dev/null +++ b/x-pack/plugins/features/public/features_api_client.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { coreMock } from 'src/core/public/mocks'; +import { FeaturesAPIClient } from './features_api_client'; + +describe('Features API Client', () => { + describe('#getFeatures', () => { + it('returns an array of Features', async () => { + const rawFeatures = [ + { + id: 'feature-a', + }, + { + id: 'feature-b', + }, + { + id: 'feature-c', + }, + { + id: 'feature-d', + }, + { + id: 'feature-e', + }, + ]; + const coreSetup = coreMock.createSetup(); + coreSetup.http.get.mockResolvedValue(rawFeatures); + + const client = new FeaturesAPIClient(coreSetup.http); + const result = await client.getFeatures(); + expect(result.map(f => f.id)).toEqual([ + 'feature-a', + 'feature-b', + 'feature-c', + 'feature-d', + 'feature-e', + ]); + }); + }); +}); diff --git a/x-pack/plugins/features/public/features_api_client.ts b/x-pack/plugins/features/public/features_api_client.ts new file mode 100644 index 00000000000000..b93c9bf917d79c --- /dev/null +++ b/x-pack/plugins/features/public/features_api_client.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. + */ + +import { HttpSetup } from 'src/core/public'; +import { FeatureConfig, Feature } from '.'; + +export class FeaturesAPIClient { + constructor(private readonly http: HttpSetup) {} + + public async getFeatures() { + const features = await this.http.get('/api/features'); + return features.map(config => new Feature(config)); + } +} diff --git a/x-pack/plugins/features/public/index.ts b/x-pack/plugins/features/public/index.ts index 6a2c99aad4bd8e..f19c7f947d97fa 100644 --- a/x-pack/plugins/features/public/index.ts +++ b/x-pack/plugins/features/public/index.ts @@ -4,4 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -export { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common'; +import { PluginInitializer } from 'src/core/public'; +import { FeaturesPlugin, FeaturesPluginSetup, FeaturesPluginStart } from './plugin'; + +export { + Feature, + FeatureConfig, + FeatureKibanaPrivileges, + SubFeatureConfig, + SubFeaturePrivilegeConfig, +} from '../common'; + +export { FeaturesPluginSetup, FeaturesPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new FeaturesPlugin(); diff --git a/x-pack/plugins/features/public/mocks.ts b/x-pack/plugins/features/public/mocks.ts new file mode 100644 index 00000000000000..014883f3ce9cf6 --- /dev/null +++ b/x-pack/plugins/features/public/mocks.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. + */ + +import { FeaturesPluginStart } from './plugin'; + +const createStart = (): jest.Mocked => { + return { + getFeatures: jest.fn(), + }; +}; + +export const featuresPluginMock = { + createStart, +}; diff --git a/x-pack/plugins/features/public/plugin.test.ts b/x-pack/plugins/features/public/plugin.test.ts new file mode 100644 index 00000000000000..aab712d6475087 --- /dev/null +++ b/x-pack/plugins/features/public/plugin.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { FeaturesPlugin } from './plugin'; + +import { coreMock, httpServiceMock } from 'src/core/public/mocks'; + +jest.mock('./features_api_client', () => { + const instance = { + getFeatures: jest.fn(), + }; + return { + FeaturesAPIClient: jest.fn().mockImplementation(() => instance), + }; +}); + +import { FeaturesAPIClient } from './features_api_client'; + +describe('Features Plugin', () => { + describe('#setup', () => { + it('returns expected public contract', () => { + const plugin = new FeaturesPlugin(); + expect(plugin.setup(coreMock.createSetup())).toMatchInlineSnapshot(`undefined`); + }); + }); + + describe('#start', () => { + it('returns expected public contract', () => { + const plugin = new FeaturesPlugin(); + plugin.setup(coreMock.createSetup()); + + expect(plugin.start()).toMatchInlineSnapshot(` + Object { + "getFeatures": [Function], + } + `); + }); + + it('#getFeatures calls the underlying FeaturesAPIClient', () => { + const plugin = new FeaturesPlugin(); + const apiClient = new FeaturesAPIClient(httpServiceMock.createSetupContract()); + + plugin.setup(coreMock.createSetup()); + + const start = plugin.start(); + start.getFeatures(); + expect(apiClient.getFeatures).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/features/public/plugin.ts b/x-pack/plugins/features/public/plugin.ts new file mode 100644 index 00000000000000..c168384dae78f3 --- /dev/null +++ b/x-pack/plugins/features/public/plugin.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 { Plugin, CoreSetup } from 'src/core/public'; +import { FeaturesAPIClient } from './features_api_client'; + +export class FeaturesPlugin implements Plugin { + private apiClient?: FeaturesAPIClient; + + public setup(core: CoreSetup) { + this.apiClient = new FeaturesAPIClient(core.http); + } + + public start() { + return { + getFeatures: () => this.apiClient!.getFeatures(), + }; + } + + public stop() {} +} + +export type FeaturesPluginSetup = ReturnType; +export type FeaturesPluginStart = ReturnType; diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap new file mode 100644 index 00000000000000..ee94d0d40b8534 --- /dev/null +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -0,0 +1,458 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`buildOSSFeatures returns the advancedSettings feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "advanced_settings", + ], + "management": Object { + "kibana": Array [ + "settings", + ], + }, + "savedObject": Object { + "all": Array [ + "config", + ], + "read": Array [], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "advanced_settings", + ], + "management": Object { + "kibana": Array [ + "settings", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the dashboard feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "dashboard", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "dashboard", + "url", + "query", + ], + "read": Array [ + "index-pattern", + "search", + "visualization", + "timelion-sheet", + "canvas-workpad", + "lens", + "map", + ], + }, + "ui": Array [ + "createNew", + "show", + "showWriteControls", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "dashboard", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "visualization", + "timelion-sheet", + "canvas-workpad", + "map", + "dashboard", + "query", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the dev_tools feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [ + "console", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "console", + "searchprofiler", + "grokdebugger", + ], + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "show", + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "api": Array [ + "console", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "console", + "searchprofiler", + "grokdebugger", + ], + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the discover feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "discover", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "search", + "query", + "url", + ], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [ + "show", + "save", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "discover", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "query", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the indexPatterns feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "index_patterns", + ], + "management": Object { + "kibana": Array [ + "index_patterns", + ], + }, + "savedObject": Object { + "all": Array [ + "index-pattern", + ], + "read": Array [], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "index_patterns", + ], + "management": Object { + "kibana": Array [ + "index_patterns", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the savedObjectsManagement feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [ + "copySavedObjectsToSpaces", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "saved_objects", + ], + "management": Object { + "kibana": Array [ + "objects", + ], + }, + "savedObject": Object { + "all": Array [ + "foo", + "bar", + ], + "read": Array [], + }, + "ui": Array [ + "read", + "edit", + "delete", + "copyIntoSpace", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "api": Array [ + "copySavedObjectsToSpaces", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "saved_objects", + ], + "management": Object { + "kibana": Array [ + "objects", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [ + "foo", + "bar", + ], + }, + "ui": Array [ + "read", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the timelion feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "timelion", + "kibana", + ], + "catalogue": Array [ + "timelion", + ], + "savedObject": Object { + "all": Array [ + "timelion-sheet", + ], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "timelion", + "kibana", + ], + "catalogue": Array [ + "timelion", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "timelion-sheet", + ], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the visualize feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [], + "app": Array [ + "kibana", + "lens", + ], + "catalogue": Array [ + "visualize", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "visualization", + "query", + "lens", + "url", + ], + "read": Array [ + "index-pattern", + "search", + ], + }, + "ui": Array [ + "show", + "delete", + "save", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + "lens", + ], + "catalogue": Array [ + "visualize", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "visualization", + "query", + "lens", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index 7b250358926689..5b4f7728c9f311 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -5,15 +5,15 @@ */ import { FeatureRegistry } from './feature_registry'; -import { Feature } from '../common/feature'; +import { FeatureConfig } from '../common/feature'; describe('FeatureRegistry', () => { it('allows a minimal feature to be registered', () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, }; const featureRegistry = new FeatureRegistry(); @@ -22,18 +22,18 @@ describe('FeatureRegistry', () => { expect(result).toHaveLength(1); // Should be the equal, but not the same instance (i.e., a defensive copy) - expect(result[0]).not.toBe(feature); - expect(result[0]).toEqual(feature); + expect(result[0].toRaw()).not.toBe(feature); + expect(result[0].toRaw()).toEqual(feature); }); it('allows a complex feature to be registered', () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', excludeFromBasePrivileges: true, icon: 'addDataApp', navLinkId: 'someNavLink', - app: ['app1', 'app2'], + app: ['app1'], validLicenses: ['standard', 'basic', 'gold', 'platinum'], catalogue: ['foo'], management: { @@ -53,7 +53,61 @@ describe('FeatureRegistry', () => { api: ['someApiEndpointTag', 'anotherEndpointTag'], ui: ['allowsFoo', 'showBar', 'showBaz'], }, + read: { + savedObject: { + all: [], + read: ['config', 'url'], + }, + ui: [], + }, }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'foo', + name: 'foo', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'bar', + name: 'bar', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + { + id: 'baz', + name: 'baz', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], privilegesTooltip: 'some fancy tooltip', reserved: { privilege: { @@ -79,12 +133,61 @@ describe('FeatureRegistry', () => { expect(result).toHaveLength(1); // Should be the equal, but not the same instance (i.e., a defensive copy) - expect(result[0]).not.toBe(feature); - expect(result[0]).toEqual(feature); + expect(result[0].toRaw()).not.toBe(feature); + expect(result[0].toRaw()).toEqual(feature); + }); + + it(`requires a value for privileges`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + } as any; + + const featureRegistry = new FeatureRegistry(); + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"child \\"privileges\\" fails because [\\"privileges\\" is required]"` + ); + }); + + it(`does not allow sub-features to be registered when no primary privileges are not registered`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'my-sub-priv', + name: 'my sub priv', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"child \\"subFeatures\\" fails because [\\"subFeatures\\" must contain less than or equal to 0 items]"` + ); }); it(`automatically grants 'all' access to telemetry saved objects for the 'all' privilege`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -96,6 +199,13 @@ describe('FeatureRegistry', () => { read: [], }, }, + read: { + ui: [], + savedObject: { + all: [], + read: [], + }, + }, }, }; @@ -103,12 +213,15 @@ describe('FeatureRegistry', () => { featureRegistry.register(feature); const result = featureRegistry.getAll(); - const allPrivilege = result[0].privileges.all; - expect(allPrivilege.savedObject.all).toEqual(['telemetry']); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); + + const allPrivilege = result[0].privileges?.all; + expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); }); it(`automatically grants 'read' access to config and url saved objects for both privileges`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -134,18 +247,21 @@ describe('FeatureRegistry', () => { featureRegistry.register(feature); const result = featureRegistry.getAll(); - const allPrivilege = result[0].privileges.all; - const readPrivilege = result[0].privileges.read; - expect(allPrivilege.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege.savedObject.read).toEqual(['config', 'url']); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); + + const allPrivilege = result[0].privileges?.all; + const readPrivilege = result[0].privileges?.read; + expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); }); it(`automatically grants 'all' access to telemetry and 'read' to [config, url] saved objects for the reserved privilege`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, reserved: { description: 'foo', privilege: { @@ -168,7 +284,7 @@ describe('FeatureRegistry', () => { }); it(`does not duplicate the automatic grants if specified on the incoming feature`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -194,26 +310,29 @@ describe('FeatureRegistry', () => { featureRegistry.register(feature); const result = featureRegistry.getAll(); - const allPrivilege = result[0].privileges.all; - const readPrivilege = result[0].privileges.read; - expect(allPrivilege.savedObject.all).toEqual(['telemetry']); - expect(allPrivilege.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege.savedObject.read).toEqual(['config', 'url']); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); + + const allPrivilege = result[0].privileges!.all; + const readPrivilege = result[0].privileges!.read; + expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); + expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); }); it(`does not allow duplicate features to be registered`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, }; - const duplicateFeature: Feature = { + const duplicateFeature: FeatureConfig = { id: 'test-feature', name: 'Duplicate Test Feature', app: [], - privileges: {}, + privileges: null, }; const featureRegistry = new FeatureRegistry(); @@ -233,7 +352,7 @@ describe('FeatureRegistry', () => { name: 'some feature', navLinkId: prohibitedChars, app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); @@ -248,7 +367,7 @@ describe('FeatureRegistry', () => { kibana: [prohibitedChars], }, app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); @@ -261,7 +380,7 @@ describe('FeatureRegistry', () => { name: 'some feature', catalogue: [prohibitedChars], app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); @@ -275,19 +394,20 @@ describe('FeatureRegistry', () => { id: prohibitedId, name: 'some feature', app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); }); it('prevents features from being registered with invalid privilege names', () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: ['app1', 'app2'], privileges: { foo: { + name: 'Foo', app: ['app1', 'app2'], savedObject: { all: ['config', 'space', 'etc'], @@ -296,7 +416,7 @@ describe('FeatureRegistry', () => { api: ['someApiEndpointTag', 'anotherEndpointTag'], ui: ['allowsFoo', 'showBar', 'showBaz'], }, - }, + } as any, }; const featureRegistry = new FeatureRegistry(); @@ -306,7 +426,7 @@ describe('FeatureRegistry', () => { }); it(`prevents privileges from specifying app entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: ['bar'], @@ -319,6 +439,14 @@ describe('FeatureRegistry', () => { ui: [], app: ['foo', 'bar', 'baz'], }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar', 'baz'], + }, }, }; @@ -329,12 +457,67 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying app entries that don't exist at the privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['foo', 'bar', 'baz'], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['bar'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo'], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents reserved privileges from specifying app entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: ['bar'], - privileges: {}, + privileges: null, reserved: { description: 'something', privilege: { @@ -355,8 +538,34 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying app entries that don't exist at the reserved privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar'], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents privileges from specifying catalogue entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -371,6 +580,15 @@ describe('FeatureRegistry', () => { ui: [], app: [], }, + read: { + catalogue: ['foo', 'bar', 'baz'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, }; @@ -381,13 +599,71 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying catalogue entries that don't exist at the privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['foo', 'bar', 'baz'], + privileges: { + all: { + catalogue: ['foo'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + catalogue: ['foo'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + catalogue: ['bar'], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents reserved privileges from specifying catalogue entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], catalogue: ['bar'], - privileges: {}, + privileges: null, reserved: { description: 'something', privilege: { @@ -409,8 +685,36 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying catalogue entries that don't exist at the reserved privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privilege: { + catalogue: ['foo', 'bar'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents privileges from specifying management sections that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -431,6 +735,18 @@ describe('FeatureRegistry', () => { ui: [], app: [], }, + read: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, }; @@ -441,8 +757,79 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying management sections that don't exist at the privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + management: { + kibana: ['hey'], + elasticsearch: ['hey', 'there'], + }, + privileges: { + all: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + management: { + kibana: ['hey'], + elasticsearch: ['hey'], + }, + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies management entries which are not granted to any privileges: elasticsearch.there"` + ); + }); + it(`prevents reserved privileges from specifying management entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -450,7 +837,7 @@ describe('FeatureRegistry', () => { management: { kibana: ['hey'], }, - privileges: {}, + privileges: null, reserved: { description: 'something', privilege: { @@ -475,18 +862,52 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying management entries that don't exist at the reserved privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + management: { + kibana: ['hey', 'hey-there'], + }, + privileges: null, + reserved: { + description: 'something', + privilege: { + catalogue: ['bar'], + management: { + kibana: ['hey-there'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies management entries which are not granted to any privileges: kibana.hey"` + ); + }); + it('cannot register feature after getAll has been called', () => { - const feature1: Feature = { + const feature1: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, }; - const feature2: Feature = { + const feature2: FeatureConfig = { id: 'test-feature-2', name: 'Test Feature 2', app: [], - privileges: {}, + privileges: null, }; const featureRegistry = new FeatureRegistry(); diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts index 60a229fc58612c..73a353cd274711 100644 --- a/x-pack/plugins/features/server/feature_registry.ts +++ b/x-pack/plugins/features/server/feature_registry.ts @@ -5,14 +5,14 @@ */ import { cloneDeep, uniq } from 'lodash'; -import { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common'; +import { FeatureConfig, Feature, FeatureKibanaPrivileges } from '../common'; import { validateFeature } from './feature_schema'; export class FeatureRegistry { private locked = false; - private features: Record = {}; + private features: Record = {}; - public register(feature: FeatureWithAllOrReadPrivileges) { + public register(feature: FeatureConfig) { if (this.locked) { throw new Error( `Features are locked, can't register new features. Attempt to register ${feature.id} failed.` @@ -25,20 +25,21 @@ export class FeatureRegistry { throw new Error(`Feature with id ${feature.id} is already registered.`); } - const featureCopy: Feature = cloneDeep(feature as Feature); + const featureCopy = cloneDeep(feature); - this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy as Feature); + this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy); } public getAll(): Feature[] { this.locked = true; - return cloneDeep(Object.values(this.features)); + return Object.values(this.features).map(featureConfig => new Feature(featureConfig)); } } -function applyAutomaticPrivilegeGrants(feature: Feature): Feature { - const { all: allPrivilege, read: readPrivilege } = feature.privileges; - const reservedPrivilege = feature.reserved ? feature.reserved.privilege : null; +function applyAutomaticPrivilegeGrants(feature: FeatureConfig): FeatureConfig { + const allPrivilege = feature.privileges?.all; + const readPrivilege = feature.privileges?.read; + const reservedPrivilege = feature.reserved?.privilege; applyAutomaticAllPrivilegeGrants(allPrivilege, reservedPrivilege); applyAutomaticReadPrivilegeGrants(readPrivilege); @@ -46,7 +47,9 @@ function applyAutomaticPrivilegeGrants(feature: Feature): Feature { return feature; } -function applyAutomaticAllPrivilegeGrants(...allPrivileges: Array) { +function applyAutomaticAllPrivilegeGrants( + ...allPrivileges: Array +) { allPrivileges.forEach(allPrivilege => { if (allPrivilege) { allPrivilege.savedObject.all = uniq([...allPrivilege.savedObject.all, 'telemetry']); @@ -56,7 +59,7 @@ function applyAutomaticAllPrivilegeGrants(...allPrivileges: Array + ...readPrivileges: Array ) { readPrivileges.forEach(readPrivilege => { if (readPrivilege) { diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index cc12ea1b78dce0..fdeceb30b4e3d9 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -8,13 +8,15 @@ import Joi from 'joi'; import { difference } from 'lodash'; import { Capabilities as UICapabilities } from '../../../../src/core/server'; -import { FeatureWithAllOrReadPrivileges } from '../common/feature'; +import { FeatureConfig } from '../common/feature'; +import { FeatureKibanaPrivileges } from '.'; // Each feature gets its own property on the UICapabilities object, // but that object has a few built-in properties which should not be overwritten. const prohibitedFeatureIds: Array = ['catalogue', 'management', 'navLinks']; const featurePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/; +const subFeaturePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/; const managementSectionIdRegex = /^[a-zA-Z0-9_-]+$/; export const uiCapabilitiesRegex = /^[a-zA-Z0-9:_-]+$/; @@ -43,12 +45,52 @@ const privilegeSchema = Joi.object({ .required(), }); +const subFeaturePrivilegeSchema = Joi.object({ + id: Joi.string() + .regex(subFeaturePrivilegePartRegex) + .required(), + name: Joi.string().required(), + includeIn: Joi.string() + .allow('all', 'read', 'none') + .required(), + management: managementSchema, + catalogue: catalogueSchema, + api: Joi.array().items(Joi.string()), + app: Joi.array().items(Joi.string()), + savedObject: Joi.object({ + all: Joi.array() + .items(Joi.string()) + .required(), + read: Joi.array() + .items(Joi.string()) + .required(), + }).required(), + ui: Joi.array() + .items(Joi.string().regex(uiCapabilitiesRegex)) + .required(), +}); + +const subFeatureSchema = Joi.object({ + name: Joi.string().required(), + privilegeGroups: Joi.array().items( + Joi.object({ + groupType: Joi.string() + .valid('mutually_exclusive', 'independent') + .required(), + privileges: Joi.array() + .items(subFeaturePrivilegeSchema) + .min(1), + }) + ), +}); + const schema = Joi.object({ id: Joi.string() .regex(featurePrivilegePartRegex) .invalid(...prohibitedFeatureIds) .required(), name: Joi.string().required(), + order: Joi.number(), excludeFromBasePrivileges: Joi.boolean(), validLicenses: Joi.array().items( Joi.string().valid('basic', 'standard', 'gold', 'platinum', 'enterprise', 'trial') @@ -64,7 +106,16 @@ const schema = Joi.object({ privileges: Joi.object({ all: privilegeSchema, read: privilegeSchema, - }).required(), + }) + .allow(null) + .required(), + subFeatures: Joi.when('privileges', { + is: null, + then: Joi.array() + .items(subFeatureSchema) + .max(0), + otherwise: Joi.array().items(subFeatureSchema), + }), privilegesTooltip: Joi.string(), reserved: Joi.object({ privilege: privilegeSchema.required(), @@ -72,7 +123,7 @@ const schema = Joi.object({ }), }); -export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { +export function validateFeature(feature: FeatureConfig) { const validateResult = Joi.validate(feature, schema); if (validateResult.error) { throw validateResult.error; @@ -80,17 +131,21 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { // the following validation can't be enforced by the Joi schema, since it'd require us looking "up" the object graph for the list of valid value, which they explicitly forbid. const { app = [], management = {}, catalogue = [] } = feature; - const privilegeEntries = [...Object.entries(feature.privileges)]; - if (feature.reserved) { - privilegeEntries.push(['reserved', feature.reserved.privilege]); - } + const unseenApps = new Set(app); - privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => { - if (!privilegeDefinition) { - throw new Error('Privilege definition may not be null or undefined'); - } + const managementSets = Object.entries(management).map(entry => [ + entry[0], + new Set(entry[1]), + ]) as Array<[string, Set]>; + + const unseenManagement = new Map>(managementSets); + + const unseenCatalogue = new Set(catalogue); + + function validateAppEntry(privilegeId: string, entry: string[] = []) { + entry.forEach(privilegeApp => unseenApps.delete(privilegeApp)); - const unknownAppEntries = difference(privilegeDefinition.app || [], app); + const unknownAppEntries = difference(entry, app); if (unknownAppEntries.length > 0) { throw new Error( `Feature privilege ${ @@ -98,8 +153,12 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { }.${privilegeId} has unknown app entries: ${unknownAppEntries.join(', ')}` ); } + } + + function validateCatalogueEntry(privilegeId: string, entry: string[] = []) { + entry.forEach(privilegeCatalogue => unseenCatalogue.delete(privilegeCatalogue)); - const unknownCatalogueEntries = difference(privilegeDefinition.catalogue || [], catalogue); + const unknownCatalogueEntries = difference(entry || [], catalogue); if (unknownCatalogueEntries.length > 0) { throw new Error( `Feature privilege ${ @@ -107,27 +166,113 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { }.${privilegeId} has unknown catalogue entries: ${unknownCatalogueEntries.join(', ')}` ); } + } - Object.entries(privilegeDefinition.management || {}).forEach( - ([managementSectionId, managementEntry]) => { - if (!management[managementSectionId]) { - throw new Error( - `Feature privilege ${feature.id}.${privilegeId} has unknown management section: ${managementSectionId}` - ); - } - - const unknownSectionEntries = difference(managementEntry, management[managementSectionId]); - - if (unknownSectionEntries.length > 0) { - throw new Error( - `Feature privilege ${ - feature.id - }.${privilegeId} has unknown management entries for section ${managementSectionId}: ${unknownSectionEntries.join( - ', ' - )}` - ); - } + function validateManagementEntry( + privilegeId: string, + managementEntry: Record = {} + ) { + Object.entries(managementEntry).forEach(([managementSectionId, managementSectionEntry]) => { + if (unseenManagement.has(managementSectionId)) { + managementSectionEntry.forEach(entry => { + unseenManagement.get(managementSectionId)!.delete(entry); + if (unseenManagement.get(managementSectionId)?.size === 0) { + unseenManagement.delete(managementSectionId); + } + }); } - ); + if (!management[managementSectionId]) { + throw new Error( + `Feature privilege ${feature.id}.${privilegeId} has unknown management section: ${managementSectionId}` + ); + } + + const unknownSectionEntries = difference( + managementSectionEntry, + management[managementSectionId] + ); + + if (unknownSectionEntries.length > 0) { + throw new Error( + `Feature privilege ${ + feature.id + }.${privilegeId} has unknown management entries for section ${managementSectionId}: ${unknownSectionEntries.join( + ', ' + )}` + ); + } + }); + } + + const privilegeEntries: Array<[string, FeatureKibanaPrivileges]> = []; + if (feature.privileges) { + privilegeEntries.push(...Object.entries(feature.privileges)); + } + if (feature.reserved) { + privilegeEntries.push(['reserved', feature.reserved.privilege]); + } + + if (privilegeEntries.length === 0) { + return; + } + + privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => { + if (!privilegeDefinition) { + throw new Error('Privilege definition may not be null or undefined'); + } + + validateAppEntry(privilegeId, privilegeDefinition.app); + + validateCatalogueEntry(privilegeId, privilegeDefinition.catalogue); + + validateManagementEntry(privilegeId, privilegeDefinition.management); + }); + + const subFeatureEntries = feature.subFeatures ?? []; + subFeatureEntries.forEach(subFeature => { + subFeature.privilegeGroups.forEach(subFeaturePrivilegeGroup => { + subFeaturePrivilegeGroup.privileges.forEach(subFeaturePrivilege => { + validateAppEntry(subFeaturePrivilege.id, subFeaturePrivilege.app); + validateCatalogueEntry(subFeaturePrivilege.id, subFeaturePrivilege.catalogue); + validateManagementEntry(subFeaturePrivilege.id, subFeaturePrivilege.management); + }); + }); }); + + if (unseenApps.size > 0) { + throw new Error( + `Feature ${ + feature.id + } specifies app entries which are not granted to any privileges: ${Array.from( + unseenApps.values() + ).join(',')}` + ); + } + + if (unseenCatalogue.size > 0) { + throw new Error( + `Feature ${ + feature.id + } specifies catalogue entries which are not granted to any privileges: ${Array.from( + unseenCatalogue.values() + ).join(',')}` + ); + } + + if (unseenManagement.size > 0) { + const ungrantedManagement = Array.from(unseenManagement.entries()).reduce((acc, entry) => { + const values = Array.from(entry[1].values()).map( + managementPage => `${entry[0]}.${managementPage}` + ); + return [...acc, ...values]; + }, [] as string[]); + + throw new Error( + `Feature ${ + feature.id + } specifies management entries which are not granted to any privileges: ${ungrantedManagement.join( + ',' + )}` + ); + } } diff --git a/x-pack/plugins/features/server/index.ts b/x-pack/plugins/features/server/index.ts index 48ef97a494f7ea..48a350ae8f8fde 100644 --- a/x-pack/plugins/features/server/index.ts +++ b/x-pack/plugins/features/server/index.ts @@ -13,7 +13,7 @@ import { Plugin } from './plugin'; // run-time contracts. export { uiCapabilitiesRegex } from './feature_schema'; -export { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common'; +export { Feature, FeatureConfig, FeatureKibanaPrivileges } from '../common'; export { PluginSetupContract, PluginStartContract } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/x-pack/plugins/features/server/oss_features.test.ts b/x-pack/plugins/features/server/oss_features.test.ts index 987af08fe7cda6..72beff02173d2b 100644 --- a/x-pack/plugins/features/server/oss_features.test.ts +++ b/x-pack/plugins/features/server/oss_features.test.ts @@ -5,6 +5,8 @@ */ import { buildOSSFeatures } from './oss_features'; +import { featurePrivilegeIterator } from '../../security/server/authorization'; +import { Feature } from '.'; describe('buildOSSFeatures', () => { it('returns features including timelion', () => { @@ -39,4 +41,17 @@ Array [ ] `); }); + + const features = buildOSSFeatures({ savedObjectTypes: ['foo', 'bar'], includeTimelion: true }); + features.forEach(featureConfig => { + it(`returns the ${featureConfig.id} feature augmented with appropriate sub feature privileges`, () => { + const privileges = []; + for (const featurePrivilege of featurePrivilegeIterator(new Feature(featureConfig), { + augmentWithSubFeaturePrivileges: true, + })) { + privileges.push(featurePrivilege); + } + expect(privileges).toMatchSnapshot(); + }); + }); }); diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index b48963ebb81396..3e8ce37fd15781 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { Feature } from '../common/feature'; +import { FeatureConfig } from '../common/feature'; export interface BuildOSSFeaturesParams { savedObjectTypes: string[]; @@ -18,19 +18,24 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.discoverFeatureName', { defaultMessage: 'Discover', }), + order: 100, icon: 'discoverApp', navLinkId: 'kibana:discover', app: ['kibana'], catalogue: ['discover'], privileges: { all: { + app: ['kibana'], + catalogue: ['discover'], savedObject: { - all: ['search', 'url', 'query'], + all: ['search', 'query'], read: ['index-pattern'], }, - ui: ['show', 'createShortUrl', 'save', 'saveQuery'], + ui: ['show', 'save', 'saveQuery'], }, read: { + app: ['kibana'], + catalogue: ['discover'], savedObject: { all: [], read: ['index-pattern', 'search', 'query'], @@ -38,25 +43,59 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show'], }, }, + subFeatures: [ + { + name: i18n.translate('xpack.features.ossFeatures.discoverShortUrlSubFeatureName', { + defaultMessage: 'Short URLs', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'url_create', + name: i18n.translate( + 'xpack.features.ossFeatures.discoverCreateShortUrlPrivilegeName', + { + defaultMessage: 'Create Short URLs', + } + ), + includeIn: 'all', + savedObject: { + all: ['url'], + read: [], + }, + ui: ['createShortUrl'], + }, + ], + }, + ], + }, + ], }, { id: 'visualize', name: i18n.translate('xpack.features.visualizeFeatureName', { defaultMessage: 'Visualize', }), + order: 200, icon: 'visualizeApp', navLinkId: 'kibana:visualize', app: ['kibana', 'lens'], catalogue: ['visualize'], privileges: { all: { + app: ['kibana', 'lens'], + catalogue: ['visualize'], savedObject: { - all: ['visualization', 'url', 'query', 'lens'], + all: ['visualization', 'query', 'lens'], read: ['index-pattern', 'search'], }, - ui: ['show', 'createShortUrl', 'delete', 'save', 'saveQuery'], + ui: ['show', 'delete', 'save', 'saveQuery'], }, read: { + app: ['kibana', 'lens'], + catalogue: ['visualize'], savedObject: { all: [], read: ['index-pattern', 'search', 'visualization', 'query', 'lens'], @@ -64,18 +103,50 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show'], }, }, + subFeatures: [ + { + name: i18n.translate('xpack.features.ossFeatures.visualizeShortUrlSubFeatureName', { + defaultMessage: 'Short URLs', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'url_create', + name: i18n.translate( + 'xpack.features.ossFeatures.visualizeCreateShortUrlPrivilegeName', + { + defaultMessage: 'Create Short URLs', + } + ), + includeIn: 'all', + savedObject: { + all: ['url'], + read: [], + }, + ui: ['createShortUrl'], + }, + ], + }, + ], + }, + ], }, { id: 'dashboard', name: i18n.translate('xpack.features.dashboardFeatureName', { defaultMessage: 'Dashboard', }), + order: 300, icon: 'dashboardApp', navLinkId: 'kibana:dashboard', app: ['kibana'], catalogue: ['dashboard'], privileges: { all: { + app: ['kibana'], + catalogue: ['dashboard'], savedObject: { all: ['dashboard', 'url', 'query'], read: [ @@ -91,6 +162,8 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'], }, read: { + app: ['kibana'], + catalogue: ['dashboard'], savedObject: { all: [], read: [ @@ -107,18 +180,50 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show'], }, }, + subFeatures: [ + { + name: i18n.translate('xpack.features.ossFeatures.dashboardShortUrlSubFeatureName', { + defaultMessage: 'Short URLs', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'url_create', + name: i18n.translate( + 'xpack.features.ossFeatures.dashboardCreateShortUrlPrivilegeName', + { + defaultMessage: 'Create Short URLs', + } + ), + includeIn: 'all', + savedObject: { + all: ['url'], + read: [], + }, + ui: ['createShortUrl'], + }, + ], + }, + ], + }, + ], }, { id: 'dev_tools', name: i18n.translate('xpack.features.devToolsFeatureName', { defaultMessage: 'Dev Tools', }), + order: 1300, icon: 'devToolsApp', navLinkId: 'kibana:dev_tools', app: ['kibana'], catalogue: ['console', 'searchprofiler', 'grokdebugger'], privileges: { all: { + app: ['kibana'], + catalogue: ['console', 'searchprofiler', 'grokdebugger'], api: ['console'], savedObject: { all: [], @@ -127,6 +232,8 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show', 'save'], }, read: { + app: ['kibana'], + catalogue: ['console', 'searchprofiler', 'grokdebugger'], api: ['console'], savedObject: { all: [], @@ -145,6 +252,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.advancedSettingsFeatureName', { defaultMessage: 'Advanced Settings', }), + order: 1500, icon: 'advancedSettingsApp', app: ['kibana'], catalogue: ['advanced_settings'], @@ -153,6 +261,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, privileges: { all: { + app: ['kibana'], + catalogue: ['advanced_settings'], + management: { + kibana: ['settings'], + }, savedObject: { all: ['config'], read: [], @@ -160,6 +273,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['save'], }, read: { + app: ['kibana'], + catalogue: ['advanced_settings'], + management: { + kibana: ['settings'], + }, savedObject: { all: [], read: [], @@ -173,6 +291,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.indexPatternFeatureName', { defaultMessage: 'Index Pattern Management', }), + order: 1600, icon: 'indexPatternApp', app: ['kibana'], catalogue: ['index_patterns'], @@ -181,6 +300,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, privileges: { all: { + app: ['kibana'], + catalogue: ['index_patterns'], + management: { + kibana: ['index_patterns'], + }, savedObject: { all: ['index-pattern'], read: [], @@ -188,6 +312,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['save'], }, read: { + app: ['kibana'], + catalogue: ['index_patterns'], + management: { + kibana: ['index_patterns'], + }, savedObject: { all: [], read: ['index-pattern'], @@ -201,6 +330,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.savedObjectsManagementFeatureName', { defaultMessage: 'Saved Objects Management', }), + order: 1700, icon: 'savedObjectsApp', app: ['kibana'], catalogue: ['saved_objects'], @@ -209,6 +339,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, privileges: { all: { + app: ['kibana'], + catalogue: ['saved_objects'], + management: { + kibana: ['objects'], + }, api: ['copySavedObjectsToSpaces'], savedObject: { all: [...savedObjectTypes], @@ -217,6 +352,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['read', 'edit', 'delete', 'copyIntoSpace'], }, read: { + app: ['kibana'], + catalogue: ['saved_objects'], + management: { + kibana: ['objects'], + }, api: ['copySavedObjectsToSpaces'], savedObject: { all: [], @@ -227,18 +367,21 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, }, ...(includeTimelion ? [timelionFeature] : []), - ]; + ] as FeatureConfig[]; }; -const timelionFeature: Feature = { +const timelionFeature: FeatureConfig = { id: 'timelion', name: 'Timelion', + order: 350, icon: 'timelionApp', navLinkId: 'timelion', app: ['timelion', 'kibana'], catalogue: ['timelion'], privileges: { all: { + app: ['timelion', 'kibana'], + catalogue: ['timelion'], savedObject: { all: ['timelion-sheet'], read: ['index-pattern'], @@ -246,6 +389,8 @@ const timelionFeature: Feature = { ui: ['save'], }, read: { + app: ['timelion', 'kibana'], + catalogue: ['timelion'], savedObject: { all: [], read: ['index-pattern', 'timelion-sheet'], diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts index e77fa218c06815..cebf67243fb28f 100644 --- a/x-pack/plugins/features/server/plugin.ts +++ b/x-pack/plugins/features/server/plugin.ts @@ -15,7 +15,7 @@ import { deepFreeze } from '../../../../src/core/utils'; import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info'; import { PluginSetupContract as TimelionSetupContract } from '../../../../src/plugins/timelion/server'; import { FeatureRegistry } from './feature_registry'; -import { Feature, FeatureWithAllOrReadPrivileges } from '../common/feature'; +import { Feature, FeatureConfig } from '../common/feature'; import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features'; import { buildOSSFeatures } from './oss_features'; import { defineRoutes } from './routes'; @@ -24,7 +24,7 @@ import { defineRoutes } from './routes'; * Describes public Features plugin contract returned at the `setup` stage. */ export interface PluginSetupContract { - registerFeature(feature: FeatureWithAllOrReadPrivileges): void; + registerFeature(feature: FeatureConfig): void; getFeatures(): Feature[]; getFeaturesUICapabilities(): UICapabilities; registerLegacyAPI: (legacyAPI: LegacyAPI) => void; diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index b0f8417b7175da..c43e2a5195fe72 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -10,6 +10,7 @@ import { defineRoutes } from './index'; import { httpServerMock, httpServiceMock } from '../../../../../src/core/server/mocks'; import { XPackInfoLicense } from '../../../../legacy/plugins/xpack_main/server/lib/xpack_info_license'; import { RequestHandler } from '../../../../../src/core/server'; +import { FeatureConfig } from '../../common'; let currentLicenseLevel: string = 'gold'; @@ -21,7 +22,23 @@ describe('GET /api/features', () => { id: 'feature_1', name: 'Feature 1', app: [], - privileges: {}, + privileges: null, + }); + + featureRegistry.register({ + id: 'feature_2', + name: 'Feature 2', + order: 2, + app: [], + privileges: null, + }); + + featureRegistry.register({ + id: 'feature_3', + name: 'Feature 2', + order: 1, + app: [], + privileges: null, }); featureRegistry.register({ @@ -29,7 +46,7 @@ describe('GET /api/features', () => { name: 'Licensed Feature', app: ['bar-app'], validLicenses: ['gold'], - privileges: {}, + privileges: null, }); const routerMock = httpServiceMock.createRouter(); @@ -51,37 +68,33 @@ describe('GET /api/features', () => { routeHandler = routerMock.get.mock.calls[0][1]; }); - it('returns a list of available features', async () => { + it('returns a list of available features, sorted by their configured order', async () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: {} } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - Object { - "app": Array [ - "bar-app", - ], - "id": "licensed_feature", - "name": "Licensed Feature", - "privileges": Object {}, - "validLicenses": Array [ - "gold", - ], - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + { + id: 'licensed_feature', + order: undefined, + }, + ]); }); it(`by default does not return features that arent allowed by current license`, async () => { @@ -90,22 +103,26 @@ describe('GET /api/features', () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: {} } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + ]); }); it(`ignoreValidLicenses=false does not return features that arent allowed by current license`, async () => { @@ -114,22 +131,26 @@ describe('GET /api/features', () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: { ignoreValidLicenses: false } } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + ]); }); it(`ignoreValidLicenses=true returns features that arent allowed by current license`, async () => { @@ -138,32 +159,29 @@ describe('GET /api/features', () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: { ignoreValidLicenses: true } } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - Object { - "app": Array [ - "bar-app", - ], - "id": "licensed_feature", - "name": "Licensed Feature", - "privileges": Object {}, - "validLicenses": Array [ - "gold", - ], - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + { + id: 'licensed_feature', + order: undefined, + }, + ]); }); }); diff --git a/x-pack/plugins/features/server/routes/index.ts b/x-pack/plugins/features/server/routes/index.ts index cf4d61ccac88b4..428500c3daa88d 100644 --- a/x-pack/plugins/features/server/routes/index.ts +++ b/x-pack/plugins/features/server/routes/index.ts @@ -31,13 +31,19 @@ export function defineRoutes({ router, featureRegistry, getLegacyAPI }: RouteDef const allFeatures = featureRegistry.getAll(); return response.ok({ - body: allFeatures.filter( - feature => - request.query.ignoreValidLicenses || - !feature.validLicenses || - !feature.validLicenses.length || - getLegacyAPI().xpackInfo.license.isOneOf(feature.validLicenses) - ), + body: allFeatures + .filter( + feature => + request.query.ignoreValidLicenses || + !feature.validLicenses || + !feature.validLicenses.length || + getLegacyAPI().xpackInfo.license.isOneOf(feature.validLicenses) + ) + .sort( + (f1, f2) => + (f1.order ?? Number.MAX_SAFE_INTEGER) - (f2.order ?? Number.MAX_SAFE_INTEGER) + ) + .map(feature => feature.toRaw()), }); } ); diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts index bb2cd82891a150..73c399878b17bc 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts @@ -5,17 +5,31 @@ */ import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features'; +import { Feature } from '.'; +import { SubFeaturePrivilegeGroupConfig } from '../common'; -function createFeaturePrivilege(key: string, capabilities: string[] = []) { +function createFeaturePrivilege(capabilities: string[] = []) { return { - [key]: { - savedObject: { - all: [], - read: [], - }, - app: [], - ui: [...capabilities], + savedObject: { + all: [], + read: [], + }, + app: [], + ui: [...capabilities], + }; +} + +function createSubFeaturePrivilege(privilegeId: string, capabilities: string[] = []) { + return { + id: privilegeId, + name: `sub-feature privilege ${privilegeId}`, + includeIn: 'none', + savedObject: { + all: [], + read: [], }, + app: [], + ui: [...capabilities], }; } @@ -27,14 +41,15 @@ describe('populateUICapabilities', () => { it('handles features with no registered capabilities', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('all'), + all: createFeaturePrivilege(), + read: createFeaturePrivilege(), }, - }, + }), ]) ).toEqual({ catalogue: {}, @@ -45,15 +60,16 @@ describe('populateUICapabilities', () => { it('augments the original uiCapabilities with registered feature capabilities', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('all', ['capability1', 'capability2']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(), }, - }, + }), ]) ).toEqual({ catalogue: {}, @@ -67,18 +83,17 @@ describe('populateUICapabilities', () => { it('combines catalogue entries from multiple features', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], catalogue: ['anotherFooEntry', 'anotherBarEntry'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), - ...createFeaturePrivilege('baz'), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, + }), ]) ).toEqual({ catalogue: { @@ -97,17 +112,75 @@ describe('populateUICapabilities', () => { it(`merges capabilities from all feature privileges`, () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4', 'capability5']), + }, + }), + ]) + ).toEqual({ + catalogue: {}, + newFeature: { + capability1: true, + capability2: true, + capability3: true, + capability4: true, + capability5: true, + }, + }); + }); + + it(`supports merging features with sub privileges`, () => { + expect( + uiCapabilitiesForFeatures([ + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), - ...createFeaturePrivilege('baz', ['capability1', 'capability5']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-1', ['capability5']), + createSubFeaturePrivilege('privilege-2', ['capability6']), + ], + } as SubFeaturePrivilegeGroupConfig, + { + groupType: 'mutually_exclusive', + privileges: [ + createSubFeaturePrivilege('privilege-3', ['capability7']), + createSubFeaturePrivilege('privilege-4', ['capability8']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + { + name: 'sub-feature-2', + privilegeGroups: [ + { + name: 'Group Name', + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-5', ['capability9', 'capability10']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + ], + }), ]) ).toEqual({ catalogue: {}, @@ -117,6 +190,11 @@ describe('populateUICapabilities', () => { capability3: true, capability4: true, capability5: true, + capability6: true, + capability7: true, + capability8: true, + capability9: true, + capability10: true, }, }); }); @@ -124,41 +202,49 @@ describe('populateUICapabilities', () => { it('supports merging multiple features with multiple privileges each', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), - ...createFeaturePrivilege('baz', ['capability1', 'capability5']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, - { + }), + new Feature({ id: 'anotherNewFeature', name: 'another new feature', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, - { + }), + new Feature({ id: 'yetAnotherNewFeature', name: 'yet another new feature', navLinkId: 'yetAnotherNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('all', ['capability1', 'capability2']), - ...createFeaturePrivilege('read', []), - ...createFeaturePrivilege('somethingInBetween', [ - 'something1', - 'something2', - 'something3', - ]), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['something1', 'something2', 'something3']), }, - }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-1', ['capability3']), + createSubFeaturePrivilege('privilege-2', ['capability4']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + ], + }), ]) ).toEqual({ anotherNewFeature: { @@ -173,11 +259,12 @@ describe('populateUICapabilities', () => { capability2: true, capability3: true, capability4: true, - capability5: true, }, yetAnotherNewFeature: { capability1: true, capability2: true, + capability3: true, + capability4: true, something1: true, something2: true, something3: true, diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.ts index a13afa854de52d..d3d32308227492 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.ts @@ -39,7 +39,14 @@ function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities { }; } - Object.values(feature.privileges).forEach(privilege => { + const featurePrivileges = Object.values(feature.privileges ?? {}); + if (feature.subFeatures) { + featurePrivileges.push( + ...feature.subFeatures.map(sf => sf.privilegeGroups.map(pg => pg.privileges)).flat(2) + ); + } + + featurePrivileges.forEach(privilege => { UIFeatureCapabilities[feature.id] = { ...UIFeatureCapabilities[feature.id], ...privilege.ui.reduce( diff --git a/x-pack/plugins/infra/server/features.ts b/x-pack/plugins/infra/server/features.ts index edf94beab43a74..5301e1e9cbd0bb 100644 --- a/x-pack/plugins/infra/server/features.ts +++ b/x-pack/plugins/infra/server/features.ts @@ -11,12 +11,15 @@ export const METRICS_FEATURE = { name: i18n.translate('xpack.infra.featureRegistry.linkInfrastructureTitle', { defaultMessage: 'Metrics', }), + order: 700, icon: 'metricsApp', navLinkId: 'metrics', app: ['infra', 'kibana'], catalogue: ['infraops'], privileges: { all: { + app: ['infra', 'kibana'], + catalogue: ['infraops'], api: ['infra'], savedObject: { all: ['infrastructure-ui-source'], @@ -25,6 +28,8 @@ export const METRICS_FEATURE = { ui: ['show', 'configureSource', 'save'], }, read: { + app: ['infra', 'kibana'], + catalogue: ['infraops'], api: ['infra'], savedObject: { all: [], @@ -40,12 +45,15 @@ export const LOGS_FEATURE = { name: i18n.translate('xpack.infra.featureRegistry.linkLogsTitle', { defaultMessage: 'Logs', }), + order: 800, icon: 'logsApp', navLinkId: 'logs', app: ['infra', 'kibana'], catalogue: ['infralogging'], privileges: { all: { + app: ['infra', 'kibana'], + catalogue: ['infralogging'], api: ['infra'], savedObject: { all: ['infrastructure-ui-source'], @@ -54,6 +62,8 @@ export const LOGS_FEATURE = { ui: ['show', 'configureSource', 'save'], }, read: { + app: ['infra', 'kibana'], + catalogue: ['infralogging'], api: ['infra'], savedObject: { all: [], diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 67737c6fe502e4..45c847fe1f68a6 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -88,6 +88,7 @@ export class IngestManagerPlugin implements Plugin { privileges: { all: { api: [`${PLUGIN_ID}-read`, `${PLUGIN_ID}-all`], + app: [PLUGIN_ID, 'kibana'], savedObject: { all: allSavedObjectTypes, read: [], @@ -96,6 +97,7 @@ export class IngestManagerPlugin implements Plugin { }, read: { api: [`${PLUGIN_ID}-read`], + app: [PLUGIN_ID, 'kibana'], savedObject: { all: [], read: allSavedObjectTypes, diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index dc42a1f7fcbbbe..674c3886c12f8c 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -70,12 +70,15 @@ export class MlServerPlugin implements Plugin { + it('should show login page and other security elements, allow RBAC but forbid role mappings, DLS, and sub-feature privileges if license is basic.', () => { const mockRawLicense = licensingMock.createLicense({ features: { security: { isEnabled: true, isAvailable: true } }, }); @@ -108,6 +112,7 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: true, + allowSubFeaturePrivileges: false, }); expect(getFeatureSpy).toHaveBeenCalledTimes(1); expect(getFeatureSpy).toHaveBeenCalledWith('security'); @@ -129,10 +134,11 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, + allowSubFeaturePrivileges: false, }); }); - it('should allow role mappings, but not DLS/FLS if license = gold', () => { + it('should allow role mappings and sub-feature privileges, but not DLS/FLS if license = gold', () => { const mockRawLicense = licensingMock.createLicense({ license: { mode: 'gold', type: 'gold' }, features: { security: { isEnabled: true, isAvailable: true } }, @@ -149,10 +155,11 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: true, + allowSubFeaturePrivileges: true, }); }); - it('should allow to login, allow RBAC, allow role mappings, and document level security if license >= platinum', () => { + it('should allow to login, allow RBAC, role mappings, sub-feature privileges, and DLS if license >= platinum', () => { const mockRawLicense = licensingMock.createLicense({ license: { mode: 'platinum', type: 'platinum' }, features: { security: { isEnabled: true, isAvailable: true } }, @@ -169,6 +176,7 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: true, allowRoleFieldLevelSecurity: true, allowRbac: true, + allowSubFeaturePrivileges: true, }); }); }); diff --git a/x-pack/plugins/security/common/licensing/license_service.ts b/x-pack/plugins/security/common/licensing/license_service.ts index 2c2039c5e2e92a..34bc44b88e40d9 100644 --- a/x-pack/plugins/security/common/licensing/license_service.ts +++ b/x-pack/plugins/security/common/licensing/license_service.ts @@ -74,6 +74,7 @@ export class SecurityLicenseService { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, + allowSubFeaturePrivileges: false, layout: rawLicense !== undefined && !rawLicense?.isAvailable ? 'error-xpack-unavailable' @@ -90,16 +91,18 @@ export class SecurityLicenseService { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, + allowSubFeaturePrivileges: false, }; } - const showRoleMappingsManagement = rawLicense.hasAtLeast('gold'); + const isLicenseGoldOrBetter = rawLicense.hasAtLeast('gold'); const isLicensePlatinumOrBetter = rawLicense.hasAtLeast('platinum'); return { showLogin: true, allowLogin: true, showLinks: true, - showRoleMappingsManagement, + showRoleMappingsManagement: isLicenseGoldOrBetter, + allowSubFeaturePrivileges: isLicenseGoldOrBetter, // Only platinum and trial licenses are compliant with field- and document-level security. allowRoleDocumentLevelSecurity: isLicensePlatinumOrBetter, allowRoleFieldLevelSecurity: isLicensePlatinumOrBetter, diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 88da416cf715b4..59d4908c67ffb1 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -8,8 +8,8 @@ export { ApiKey, ApiKeyToInvalidate } from './api_key'; export { User, EditUser, getUserDisplayName } from './user'; export { AuthenticatedUser, canUserChangePassword } from './authenticated_user'; export { BuiltinESPrivileges } from './builtin_es_privileges'; -export { FeaturesPrivileges } from './features_privileges'; export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; +export { FeaturesPrivileges } from './features_privileges'; export { Role, RoleIndexPrivilege, @@ -22,7 +22,6 @@ export { prepareRoleClone, getExtendedRoleDeprecationNotice, } from './role'; -export { KibanaPrivileges } from './kibana_privileges'; export { InlineRoleTemplate, StoredRoleTemplate, diff --git a/x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts deleted file mode 100644 index fd4cdf33028ebe..00000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 { FeaturesPrivileges } from '../features_privileges'; -import { RawKibanaFeaturePrivileges } from '../raw_kibana_privileges'; - -export class KibanaFeaturePrivileges { - constructor(private readonly featurePrivilegesMap: RawKibanaFeaturePrivileges) {} - - public getAllPrivileges(): FeaturesPrivileges { - return Object.entries(this.featurePrivilegesMap).reduce((acc, [featureId, privileges]) => { - return { - ...acc, - [featureId]: Object.keys(privileges), - }; - }, {}); - } - - public getPrivileges(featureId: string): string[] { - const featurePrivileges = this.featurePrivilegesMap[featureId]; - if (featurePrivileges == null) { - return []; - } - - return Object.keys(featurePrivileges); - } - - 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/kibana_privileges/global_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts deleted file mode 100644 index ffe55b813217fb..00000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 KibanaGlobalPrivileges { - 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/kibana_privileges/kibana_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts deleted file mode 100644 index 61e5f083a7798a..00000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { RawKibanaPrivileges } from '../raw_kibana_privileges'; -import { KibanaFeaturePrivileges } from './feature_privileges'; -import { KibanaGlobalPrivileges } from './global_privileges'; -import { KibanaSpacesPrivileges } from './spaces_privileges'; - -export class KibanaPrivileges { - constructor(private readonly rawKibanaPrivileges: RawKibanaPrivileges) {} - - public getGlobalPrivileges() { - return new KibanaGlobalPrivileges(this.rawKibanaPrivileges.global); - } - - public getSpacesPrivileges() { - return new KibanaSpacesPrivileges(this.rawKibanaPrivileges.space); - } - - public getFeaturePrivileges() { - return new KibanaFeaturePrivileges(this.rawKibanaPrivileges.features); - } -} diff --git a/x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts deleted file mode 100644 index 5c8b4196a2b556..00000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 KibanaSpacesPrivileges { - 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/public/management/roles/__fixtures__/kibana_features.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts new file mode 100644 index 00000000000000..68d352363d3637 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Feature, FeatureConfig } from '../../../../../features/public'; + +export const createFeature = ( + config: Pick & { + excludeFromBaseAll?: boolean; + excludeFromBaseRead?: boolean; + } +) => { + const { excludeFromBaseAll, excludeFromBaseRead, ...rest } = config; + return new Feature({ + icon: 'discoverApp', + navLinkId: 'kibana:discover', + app: [], + catalogue: [], + privileges: { + all: { + excludeFromBasePrivileges: excludeFromBaseAll, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['read-ui', 'all-ui', `read-${config.id}`, `all-${config.id}`], + }, + read: { + excludeFromBasePrivileges: excludeFromBaseRead, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['read-ui', `read-${config.id}`], + }, + }, + ...rest, + }); +}; + +export const kibanaFeatures = [ + createFeature({ + id: 'no_sub_features', + name: 'Feature 1: No Sub Features', + }), + createFeature({ + id: 'with_sub_features', + name: 'Mutually Exclusive Sub Features', + subFeatures: [ + { + name: 'Cool Sub Feature', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'cool_all', + name: 'All', + includeIn: 'all', + savedObject: { + all: ['all-cool-type'], + read: ['read-cool-type'], + }, + ui: ['cool_read-ui', 'cool_all-ui'], + }, + { + id: 'cool_read', + name: 'Read', + includeIn: 'read', + savedObject: { + all: [], + read: ['read-cool-type'], + }, + ui: ['cool_read-ui'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'cool_toggle_1', + name: 'Cool toggle 1', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_1-ui'], + }, + { + id: 'cool_toggle_2', + name: 'Cool toggle 2', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_2-ui'], + }, + { + id: 'cool_excluded_toggle', + name: 'Cool excluded toggle', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_excluded_toggle-ui'], + }, + ], + }, + ], + }, + ], + }), + createFeature({ + id: 'with_excluded_sub_features', + name: 'Excluded Sub Features', + subFeatures: [ + { + name: 'Excluded Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool_toggle_1', + name: 'Cool toggle 1', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_1-ui'], + }, + ], + }, + ], + }, + ], + }), + createFeature({ + id: 'excluded_from_base', + name: 'Excluded from base', + excludeFromBaseAll: true, + excludeFromBaseRead: true, + subFeatures: [ + { + name: 'Cool Sub Feature', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'cool_all', + name: 'All', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_read-ui', 'cool_all-ui'], + }, + { + id: 'cool_read', + name: 'Read', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_read-ui'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'cool_toggle_1', + name: 'Cool toggle 2', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_1-ui'], + }, + { + id: 'cool_toggle_2', + name: 'Cool toggle 2', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_2-ui'], + }, + ], + }, + ], + }, + ], + }), +]; diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts new file mode 100644 index 00000000000000..98110a83103aa0 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Actions } from '../../../../server/authorization'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { privilegesFactory } from '../../../../server/authorization/privileges'; +import { Feature } from '../../../../../features/public'; +import { KibanaPrivileges } from '../model'; +import { SecurityLicenseFeatures } from '../../..'; + +export const createRawKibanaPrivileges = ( + features: Feature[], + { allowSubFeaturePrivileges = true } = {} +) => { + const featuresService = { + getFeatures: () => features, + }; + + const licensingService = { + getFeatures: () => ({ allowSubFeaturePrivileges } as SecurityLicenseFeatures), + }; + + return privilegesFactory( + new Actions('unit_test_version'), + featuresService, + licensingService + ).get(); +}; + +export const createKibanaPrivileges = ( + features: Feature[], + { allowSubFeaturePrivileges = true } = {} +) => { + return new KibanaPrivileges( + createRawKibanaPrivileges(features, { allowSubFeaturePrivileges }), + features + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index 23a3f327a2c5ce..f1ee6813310054 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -10,16 +10,10 @@ import { act } from '@testing-library/react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { Capabilities } from 'src/core/public'; import { Feature } from '../../../../../features/public'; -// These modules should be moved into a common directory -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Actions } from '../../../../server/authorization/actions'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { privilegesFactory } from '../../../../server/authorization/privileges'; import { Role } from '../../../../common/model'; import { DocumentationLinksService } from '../documentation_links'; import { EditRolePage } from './edit_role_page'; import { SimplePrivilegeSection } from './privileges/kibana/simple_privilege_section'; -import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section'; import { TransformErrorSection } from './privileges/kibana/transform_error_section'; import { coreMock } from '../../../../../../../src/core/public/mocks'; @@ -28,10 +22,12 @@ import { licenseMock } from '../../../../common/licensing/index.mock'; import { userAPIClientMock } from '../../users/index.mock'; import { rolesAPIClientMock, indicesAPIClientMock, privilegesAPIClientMock } from '../index.mock'; import { Space } from '../../../../../spaces/public'; +import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section'; +import { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges'; const buildFeatures = () => { return [ - { + new Feature({ id: 'feature1', name: 'Feature 1', icon: 'addDataApp', @@ -45,9 +41,17 @@ const buildFeatures = () => { read: [], }, }, + read: { + app: ['feature1App'], + ui: ['feature1-ui'], + savedObject: { + all: [], + read: [], + }, + }, }, - }, - { + }), + new Feature({ id: 'feature2', name: 'Feature 2', icon: 'addDataApp', @@ -61,17 +65,19 @@ const buildFeatures = () => { read: ['config'], }, }, + read: { + app: ['feature2App'], + ui: ['feature2-ui'], + savedObject: { + all: [], + read: ['config'], + }, + }, }, - }, + }), ] as Feature[]; }; -const buildRawKibanaPrivileges = () => { - return privilegesFactory(new Actions('unit_test_version'), { - getFeatures: () => buildFeatures(), - }).get(); -}; - const buildBuiltinESPrivileges = () => { return { cluster: ['all', 'manage', 'monitor'], @@ -144,7 +150,7 @@ function getProps({ userAPIClient.getUsers.mockResolvedValue([]); const privilegesAPIClient = privilegesAPIClientMock.create(); - privilegesAPIClient.getAll.mockResolvedValue(buildRawKibanaPrivileges()); + privilegesAPIClient.getAll.mockResolvedValue(createRawKibanaPrivileges(buildFeatures())); privilegesAPIClient.getBuiltIn.mockResolvedValue(buildBuiltinESPrivileges()); const license = licenseMock.create(); @@ -156,10 +162,6 @@ function getProps({ const { fatalErrors } = coreMock.createSetup(); const { http, docLinks, notifications } = coreMock.createStart(); http.get.mockImplementation(async (path: any) => { - if (path === '/api/features') { - return buildFeatures(); - } - if (path === '/api/spaces/space') { return buildSpaces(); } @@ -175,6 +177,7 @@ function getProps({ privilegesAPIClient, rolesAPIClient, userAPIClient, + getFeatures: () => Promise.resolve(buildFeatures()), notifications, docLinks: new DocumentationLinksService(docLinks), fatalErrors, @@ -200,10 +203,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(1); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); @@ -226,10 +226,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); @@ -240,10 +237,7 @@ describe('', () => { it('can render when creating a new role', async () => { const wrapper = mountWithIntl(); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); @@ -275,10 +269,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); @@ -301,10 +292,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); @@ -333,10 +321,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(TransformErrorSection)).toHaveLength(1); expectReadOnlyFormButtons(wrapper); @@ -360,10 +345,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(1); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); @@ -387,10 +369,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); @@ -403,10 +382,7 @@ describe('', () => { ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); expectSaveFormButtons(wrapper); @@ -438,10 +414,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); expectSaveFormButtons(wrapper); @@ -464,10 +437,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); @@ -497,10 +467,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(TransformErrorSection)).toHaveLength(1); expectReadOnlyFormButtons(wrapper); @@ -522,10 +489,7 @@ describe('', () => { const wrapper = mountWithIntl(); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); @@ -540,13 +504,17 @@ describe('', () => { ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); expectSaveFormButtons(wrapper); }); }); + +async function waitForRender(wrapper: ReactWrapper) { + await act(async () => { + await nextTick(); + wrapper.update(); + }); +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx index cd7766ef387483..f0d5abf89dd2e9 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx @@ -37,11 +37,11 @@ import { IHttpFetchError, NotificationsStart, } from 'src/core/public'; +import { FeaturesPluginStart } from '../../../../../features/public'; +import { Feature } from '../../../../../features/common'; import { IndexPatternsContract } from '../../../../../../../src/plugins/data/public'; import { Space } from '../../../../../spaces/public'; -import { Feature } from '../../../../../features/public'; import { - KibanaPrivileges, RawKibanaPrivileges, Role, BuiltinESPrivileges, @@ -64,6 +64,7 @@ import { DocumentationLinksService } from '../documentation_links'; import { IndicesAPIClient } from '../indices_api_client'; import { RolesAPIClient } from '../roles_api_client'; import { PrivilegesAPIClient } from '../privileges_api_client'; +import { KibanaPrivileges } from '../model'; interface Props { action: 'edit' | 'clone'; @@ -73,6 +74,7 @@ interface Props { indicesAPIClient: PublicMethodsOf; rolesAPIClient: PublicMethodsOf; privilegesAPIClient: PublicMethodsOf; + getFeatures: FeaturesPluginStart['getFeatures']; docLinks: DocumentationLinksService; http: HttpStart; license: SecurityLicense; @@ -231,11 +233,13 @@ function useSpaces(http: HttpStart, fatalErrors: FatalErrorsSetup, spacesEnabled return spaces; } -function useFeatures(http: HttpStart, fatalErrors: FatalErrorsSetup) { +function useFeatures( + getFeatures: FeaturesPluginStart['getFeatures'], + fatalErrors: FatalErrorsSetup +) { const [features, setFeatures] = useState(null); useEffect(() => { - http - .get('/api/features') + getFeatures() .catch((err: IHttpFetchError) => { // Currently, the `/api/features` endpoint effectively requires the "Global All" kibana privilege (e.g., what // the `kibana_user` grants), because it returns information about all registered features (#35841). It's @@ -246,14 +250,15 @@ function useFeatures(http: HttpStart, fatalErrors: FatalErrorsSetup) { // 404 here, and respond in a way that still allows the UI to render itself. const unauthorizedForFeatures = err.response?.status === 404; if (unauthorizedForFeatures) { - return []; + return [] as Feature[]; } fatalErrors.add(err); - throw err; }) - .then(setFeatures); - }, [http, fatalErrors]); + .then(retrievedFeatures => { + setFeatures(retrievedFeatures); + }); + }, [fatalErrors, getFeatures]); return features; } @@ -268,6 +273,7 @@ export const EditRolePage: FunctionComponent = ({ rolesAPIClient, indicesAPIClient, privilegesAPIClient, + getFeatures, http, roleName, action, @@ -287,7 +293,7 @@ export const EditRolePage: FunctionComponent = ({ const indexPatternsTitles = useIndexPatternsTitles(indexPatterns, fatalErrors, notifications); const privileges = usePrivileges(privilegesAPIClient, fatalErrors); const spaces = useSpaces(http, fatalErrors, spacesEnabled); - const features = useFeatures(http, fatalErrors); + const features = useFeatures(getFeatures, fatalErrors); const [role, setRole] = useRole( rolesAPIClient, fatalErrors, @@ -425,11 +431,11 @@ export const EditRolePage: FunctionComponent = ({
{ it('returns true if no spaces are defined', () => { @@ -47,39 +47,3 @@ describe('isGlobalPrivilegeDefinition', () => { ).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/management/roles/edit_role/privilege_utils.ts b/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts index 3fd8536951967a..1fad9057665daa 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts @@ -16,12 +16,3 @@ export function isGlobalPrivilegeDefinition(privilegeSpec: RoleKibanaPrivilege): } return privilegeSpec.spaces.includes('*'); } - -/** - * Determines if the passed privilege spec defines feature privileges. - * @param privilegeSpec - */ -export function hasAssignedFeaturePrivileges(privilegeSpec: RoleKibanaPrivilege): 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/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap index 617335dc9fb345..a911455f95b5d7 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap @@ -5,33 +5,17 @@ exports[` renders without crashing 1`] = ` iconType="logoKibana" title="Kibana" > - ) { + const allExpanderButtons = findTestSubject(wrapper, 'expandFeaturePrivilegeRow'); + allExpanderButtons.forEach(button => button.simulate('click')); + + // each expanded row renders its own `EuiTableRow`, so there are 2 rows + // for each feature: one for the primary feature privilege, and one for the sub privilege form + const rows = wrapper.find(EuiTableRow); + + return rows.reduce((acc, row) => { + const subFeaturePrivileges = []; + const subFeatureForm = row.find(SubFeatureForm); + if (subFeatureForm.length > 0) { + const { featureId } = subFeatureForm.props(); + const independentPrivileges = (subFeatureForm.find(EuiCheckbox) as ReactWrapper< + EuiCheckboxProps + >).reduce((acc2, checkbox) => { + const { id: privilegeId, checked } = checkbox.props(); + return checked ? [...acc2, privilegeId] : acc2; + }, [] as string[]); + + const mutuallyExclusivePrivileges = (subFeatureForm.find(EuiButtonGroup) as ReactWrapper< + EuiButtonGroupProps + >).reduce((acc2, subPrivButtonGroup) => { + const { idSelected: selectedSubPrivilege } = subPrivButtonGroup.props(); + return selectedSubPrivilege && selectedSubPrivilege !== 'none' + ? [...acc2, selectedSubPrivilege] + : acc2; + }, [] as string[]); + + subFeaturePrivileges.push(...independentPrivileges, ...mutuallyExclusivePrivileges); + + return { + ...acc, + [featureId]: { + ...acc[featureId], + subFeaturePrivileges, + }, + }; + } else { + const buttonGroup = row.find(EuiButtonGroup); + const { name, idSelected } = buttonGroup.props(); + expect(name).toBeDefined(); + expect(idSelected).toBeDefined(); + + const featureId = name!.substr(`featurePrivilege_`.length); + const primaryFeaturePrivilege = idSelected!.substr(`${featureId}_`.length); + + return { + ...acc, + [featureId]: { + ...acc[featureId], + primaryFeaturePrivilege, + }, + }; + } + }, {} as Record); +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap deleted file mode 100644 index 799ff205e2540e..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap +++ /dev/null @@ -1,39 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FeatureTable can render without spaces 1`] = ` - - - - , - "render": [Function], - }, - ] - } - items={Array []} - responsive={false} - tableLayout="fixed" -/> -`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx index c480f33b578997..2083778e539980 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx @@ -7,9 +7,11 @@ import { EuiContextMenuItem, EuiContextMenuPanel, EuiLink, EuiPopover } from '@e import { FormattedMessage } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { Component } from 'react'; +import { KibanaPrivilege } from '../../../../model'; +import { NO_PRIVILEGE_VALUE } from '../constants'; interface Props { onChange: (privilege: string) => void; - privileges: string[]; + privileges: KibanaPrivilege[]; disabled?: boolean; } @@ -24,7 +26,11 @@ export class ChangeAllPrivilegesControl extends Component { public render() { const button = ( - + { const items = this.props.privileges.map(privilege => { return ( { - this.onSelectPrivilege(privilege); + this.onSelectPrivilege(privilege.id); }} disabled={this.props.disabled} > - {_.capitalize(privilege)} + {_.capitalize(privilege.id)} ); }); + items.push( + { + this.onSelectPrivilege(NO_PRIVILEGE_VALUE); + }} + disabled={this.props.disabled} + > + {_.capitalize(NO_PRIVILEGE_VALUE)} + + ); + return ( { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, }; - spacesPrivileges?: Array<{ - spaces: string[]; - base: string[]; - feature: FeaturesPrivileges; - }>; +}; + +interface TestConfig { + features: Feature[]; + role: Role; + privilegeIndex: number; + calculateDisplayedPrivileges: boolean; + canCustomizeSubFeaturePrivileges: boolean; } -const buildRole = (options: BuildRoleOpts = {}) => { - const role: Role = { - name: 'unit test role', - elasticsearch: { - indices: [], - cluster: [], - run_as: [], - }, - kibana: [], +const setup = (config: TestConfig) => { + const kibanaPrivileges = createKibanaPrivileges(config.features, { + allowSubFeaturePrivileges: config.canCustomizeSubFeaturePrivileges, + }); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, config.role); + const onChange = jest.fn(); + const onChangeAll = jest.fn(); + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = config.calculateDisplayedPrivileges + ? getDisplayedFeaturePrivileges(wrapper) + : undefined; + + return { + wrapper, + onChange, + onChangeAll, + displayedPrivileges, }; +}; + +describe('FeatureTable', () => { + [true, false].forEach(canCustomizeSubFeaturePrivileges => { + describe(`with sub feature privileges ${ + canCustomizeSubFeaturePrivileges ? 'allowed' : 'disallowed' + }`, () => { + it('renders with no granted privileges for an empty role', () => { + const role = createRole([ + { + spaces: [], + base: [], + feature: {}, + }, + ]); + + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges, + }); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + with_sub_features: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + }); + }); + + it('renders with all included privileges granted at the space when space base privilege is "all"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: ['all'], + feature: {}, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges, + }); + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + no_sub_features: { + primaryFeaturePrivilege: 'all', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'all', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + with_sub_features: { + primaryFeaturePrivilege: 'all', + ...(canCustomizeSubFeaturePrivileges + ? { + subFeaturePrivileges: [ + 'with_sub_features_cool_toggle_1', + 'with_sub_features_cool_toggle_2', + 'cool_all', + ], + } + : {}), + }, + }); + }); + + it('renders the most permissive primary feature privilege when multiple are assigned', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['read', 'minimal_all', 'all', 'minimal_read'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges, + }); - if (options.globalPrivilege) { - role.kibana.push({ - spaces: ['*'], - ...options.globalPrivilege, + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + with_sub_features: { + primaryFeaturePrivilege: 'all', + ...(canCustomizeSubFeaturePrivileges + ? { + subFeaturePrivileges: [ + 'with_sub_features_cool_toggle_1', + 'with_sub_features_cool_toggle_2', + 'cool_all', + ], + } + : {}), + }, + }); + }); + + it('allows all feature privileges to be toggled via "change all"', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ]); + const { wrapper, onChangeAll } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges, + }); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); + + expect(onChangeAll).toHaveBeenCalledWith(['read']); + }); + + it('allows all feature privileges to be unassigned via "change all"', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['all'], + no_sub_features: ['read'], + with_excluded_sub_features: ['all', 'something else'], + }, + }, + ]); + const { wrapper, onChangeAll } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges, + }); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-none').simulate('click'); + + expect(onChangeAll).toHaveBeenCalledWith([]); + }); + }); + }); + + it('renders the most permissive sub-feature privilege when multiple are assigned in a mutually-exclusive group', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all', 'cool_read'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, }); - } - if (options.spacesPrivileges) { - role.kibana.push(...options.spacesPrivileges); - } + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + with_sub_features: { + primaryFeaturePrivilege: 'read', + subFeaturePrivileges: ['cool_all'], + }, + }); + }); - return role; -}; + it('renders a row expander only for features with sub-features', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ]); + const { wrapper } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges: true, + }); -const buildFeatures = () => { - return []; -}; + kibanaFeatures.forEach(feature => { + const rowExpander = findTestSubject(wrapper, `expandFeaturePrivilegeRow-${feature.id}`); + if (!feature.subFeatures || feature.subFeatures.length === 0) { + expect(rowExpander).toHaveLength(0); + } else { + expect(rowExpander).toHaveLength(1); + } + }); + }); -describe('FeatureTable', () => { - it('can render without spaces', () => { - const role = buildRole({ - spacesPrivileges: [ + it('renders the when the row is expanded', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ]); + const { wrapper } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges: true, + }); + + expect(wrapper.find(FeatureTableExpandedRow)).toHaveLength(0); + + findTestSubject(wrapper, 'expandFeaturePrivilegeRow') + .first() + .simulate('click'); + + expect(wrapper.find(FeatureTableExpandedRow)).toHaveLength(1); + }); + + it('renders with sub-feature privileges granted when primary feature privilege is "all"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['all'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ { - spaces: ['marketing', 'default'], - base: ['read'], - feature: { - feature1: ['all'], - }, + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: ['unit_test_sub-toggle-1', 'unit_test_sub-toggle-2', 'sub-option-1'], + }, + }); + }); + + it('renders with some sub-feature privileges granted when primary feature privilege is "read"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['read'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ + { + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'read', + subFeaturePrivileges: ['unit_test_sub-toggle-2', 'sub-toggle-2'], + }, + }); + }); + + it('renders with excluded sub-feature privileges not granted when primary feature privilege is "all"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['all'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ + { + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: ['unit_test_sub-toggle-2', 'sub-option-1'], + }, + }); + }); + + it('renders with excluded sub-feature privileges granted when explicitly assigned', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['all', 'sub-toggle-1'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ + { + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: ['unit_test_sub-toggle-1', 'unit_test_sub-toggle-2', 'sub-option-1'], + }, + }); + }); + + it('renders with all included sub-feature privileges granted at the space when primary feature privileges are granted', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['all'], }, - ], - }); - - const calculator = new KibanaPrivilegeCalculatorFactory(defaultPrivilegeDefinition).getInstance( - role - ); - - const wrapper = shallowWithIntl( - - ); - - expect(wrapper).toMatchSnapshot(); + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + with_sub_features: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: [ + 'with_sub_features_cool_toggle_1', + 'with_sub_features_cool_toggle_2', + 'cool_all', + ], + }, + }); }); - it('can render for a specific spaces entry', () => { - const role = buildRole(); - const calculator = new KibanaPrivilegeCalculatorFactory(defaultPrivilegeDefinition).getInstance( - role - ); - const wrapper = mountWithIntl( - - ); - - expect(wrapper.find(EuiInMemoryTable)).toHaveLength(1); + it('renders with no privileges granted when minimal feature privileges are assigned, and sub-feature privileges are disallowed', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['minimal_all'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: false, + }); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_sub_features: { + primaryFeaturePrivilege: 'none', + }, + }); + }); + + it('renders with no privileges granted when sub feature privileges are assigned, and sub-feature privileges are disallowed', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: false, + }); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_sub_features: { + primaryFeaturePrivilege: 'none', + }, + }); }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx index 8283efe23260af..4610da95e96493 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx @@ -4,103 +4,112 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; -import React, { Component } from 'react'; import { EuiButtonGroup, - EuiIcon, EuiIconTip, EuiInMemoryTable, EuiText, - IconType, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Feature } from '../../../../../../../../features/public'; -import { FeaturesPrivileges, KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { - AllowedPrivilege, - CalculatedPrivilege, - PrivilegeExplanation, -} from '../kibana_privilege_calculator'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { PrivilegeDisplay } from '../space_aware_privilege_section/privilege_display'; +import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; +import React, { Component } from 'react'; +import { Role } from '../../../../../../../common/model'; import { ChangeAllPrivilegesControl } from './change_all_privileges'; +import { FeatureTableExpandedRow } from './feature_table_expanded_row'; +import { NO_PRIVILEGE_VALUE } from '../constants'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { FeatureTableCell } from '../feature_table_cell'; +import { KibanaPrivileges, SecuredFeature, KibanaPrivilege } from '../../../../model'; interface Props { role: Role; - features: Feature[]; - calculatedPrivileges: CalculatedPrivilege; - allowedPrivileges: AllowedPrivilege; - rankedFeaturePrivileges: FeaturesPrivileges; + privilegeCalculator: PrivilegeFormCalculator; kibanaPrivileges: KibanaPrivileges; - spacesIndex: number; + privilegeIndex: number; onChange: (featureId: string, privileges: string[]) => void; onChangeAll: (privileges: string[]) => void; + canCustomizeSubFeaturePrivileges: boolean; disabled?: boolean; } -interface TableFeature extends Feature { - hasAnyPrivilegeAssigned: boolean; +interface State { + expandedFeatures: string[]; } interface TableRow { - feature: TableFeature; + featureId: string; + feature: SecuredFeature; + inherited: KibanaPrivilege[]; + effective: KibanaPrivilege[]; role: Role; } -export class FeatureTable extends Component { +export class FeatureTable extends Component { public static defaultProps = { - spacesIndex: -1, + privilegeIndex: -1, showLocks: true, }; + constructor(props: Props) { + super(props); + this.state = { + expandedFeatures: [], + }; + } + public render() { - const { role, features, calculatedPrivileges, rankedFeaturePrivileges } = this.props; + const { role, kibanaPrivileges } = this.props; + + const featurePrivileges = kibanaPrivileges.getSecuredFeatures(); - const items: TableRow[] = features + const items: TableRow[] = featurePrivileges .sort((feature1, feature2) => { - if ( - Object.keys(feature1.privileges).length === 0 && - Object.keys(feature2.privileges).length > 0 - ) { + if (feature1.reserved && !feature2.reserved) { return 1; } - if ( - Object.keys(feature2.privileges).length === 0 && - Object.keys(feature1.privileges).length > 0 - ) { + if (feature2.reserved && !feature1.reserved) { return -1; } return 0; }) .map(feature => { - const calculatedFeaturePrivileges = calculatedPrivileges.feature[feature.id]; - const hasAnyPrivilegeAssigned = Boolean( - calculatedFeaturePrivileges && - calculatedFeaturePrivileges.actualPrivilege !== NO_PRIVILEGE_VALUE - ); return { - feature: { - ...feature, - hasAnyPrivilegeAssigned, - }, + featureId: feature.id, + feature, + inherited: [], + effective: [], role, }; }); - // TODO: This simply grabs the available privileges from the first feature we encounter. - // As of now, features can have 'all' and 'read' as available privileges. Once that assumption breaks, - // this will need updating. This is a simplifying measure to enable the new UI. - const availablePrivileges = Object.values(rankedFeaturePrivileges)[0]; - return ( { + return { + ...acc, + [featureId]: ( + f.id === featureId)!} + privilegeIndex={this.props.privilegeIndex} + onChange={this.props.onChange} + privilegeCalculator={this.props.privilegeCalculator} + selectedFeaturePrivileges={ + this.props.role.kibana[this.props.privilegeIndex].feature[featureId] ?? [] + } + disabled={this.props.disabled} + /> + ), + }; + }, {})} items={items} /> ); @@ -115,171 +124,157 @@ export class FeatureTable extends Component { } }; - private getColumns = (availablePrivileges: string[]) => [ - { - field: 'feature', - name: i18n.translate( - 'xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle', - { defaultMessage: 'Feature' } - ), - render: (feature: TableFeature) => { - let tooltipElement = null; - if (feature.privilegesTooltip) { - const tooltipContent = ( - -

{feature.privilegesTooltip}

-
- ); - tooltipElement = ( - { + const basePrivileges = this.props.kibanaPrivileges.getBasePrivileges( + this.props.role.kibana[this.props.privilegeIndex] + ); + + const columns = []; + + if (this.props.canCustomizeSubFeaturePrivileges) { + columns.push({ + width: '30px', + isExpander: true, + field: 'featureId', + name: '', + render: (featureId: string, record: TableRow) => { + const { feature } = record; + const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0; + if (!hasSubFeaturePrivileges) { + return null; + } + return ( + this.toggleExpandedFeature(featureId)} + data-test-subj={`expandFeaturePrivilegeRow expandFeaturePrivilegeRow-${featureId}`} + aria-label={this.state.expandedFeatures.includes(featureId) ? 'Collapse' : 'Expand'} + iconType={this.state.expandedFeatures.includes(featureId) ? 'arrowUp' : 'arrowDown'} /> ); - } + }, + }); + } - return ( - - - {feature.name} {tooltipElement} - - ); + columns.push( + { + field: 'feature', + width: '200px', + name: i18n.translate( + 'xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle', + { + defaultMessage: 'Feature', + } + ), + render: (feature: SecuredFeature) => { + return ; + }, }, - }, - { - field: 'privilege', - name: ( - - - {!this.props.disabled && ( - - )} - - ), - render: (roleEntry: Role, record: TableRow) => { - const { id: featureId, name: featureName, reserved, privileges } = record.feature; - - if (reserved && Object.keys(privileges).length === 0) { - return {reserved.description}; - } - - const featurePrivileges = this.props.kibanaPrivileges - .getFeaturePrivileges() - .getPrivileges(featureId); - - if (featurePrivileges.length === 0) { - return null; - } - - const enabledFeaturePrivileges = this.getEnabledFeaturePrivileges( - featurePrivileges, - featureId - ); - - const privilegeExplanation = this.getPrivilegeExplanation(featureId); - - const allowsNone = this.allowsNoneForPrivilegeAssignment(featureId); - - const actualPrivilegeValue = privilegeExplanation.actualPrivilege; - - const canChangePrivilege = - !this.props.disabled && (allowsNone || enabledFeaturePrivileges.length > 1); - - if (!canChangePrivilege) { - const assignedBasePrivilege = - this.props.role.kibana[this.props.spacesIndex].base.length > 0; - - const excludedFromBasePrivilegsTooltip = ( + { + field: 'privilege', + width: '200px', + name: ( + + {!this.props.disabled && ( + + )} + + ), + mobileOptions: { + // Table isn't responsive, so skip rendering this for mobile. isn't free... + header: false, + }, + render: (roleEntry: Role, record: TableRow) => { + const { feature } = record; + + if (feature.reserved) { + return {feature.reserved.description}; + } + + const primaryFeaturePrivileges = feature.getPrimaryFeaturePrivileges(); + + if (primaryFeaturePrivileges.length === 0) { + return null; + } + + const selectedPrivilegeId = this.props.privilegeCalculator.getDisplayedPrimaryFeaturePrivilegeId( + feature.id, + this.props.privilegeIndex ); + const options = primaryFeaturePrivileges.map(privilege => { + return { + id: `${feature.id}_${privilege.id}`, + label: privilege.name, + isDisabled: this.props.disabled, + }; + }); + + options.push({ + id: `${feature.id}_${NO_PRIVILEGE_VALUE}`, + label: 'None', + isDisabled: this.props.disabled, + }); + + let warningIcon = ; + if ( + this.props.privilegeCalculator.hasCustomizedSubFeaturePrivileges( + feature.id, + this.props.privilegeIndex + ) + ) { + warningIcon = ( + + } + /> + ); + } + return ( - + + {warningIcon} + + + + ); - } - - const options = availablePrivileges.map(priv => { - return { - id: `${featureId}_${priv}`, - label: _.capitalize(priv), - isDisabled: !enabledFeaturePrivileges.includes(priv), - }; - }); - - options.push({ - id: `${featureId}_${NO_PRIVILEGE_VALUE}`, - label: 'None', - isDisabled: !allowsNone, - }); - - return ( - - ); - }, - }, - ]; - - private getEnabledFeaturePrivileges = (featurePrivileges: string[], featureId: string) => { - const { allowedPrivileges } = this.props; - - if (this.isConfiguringGlobalPrivileges()) { - // Global feature privileges are not limited by effective privileges. - return featurePrivileges; - } - - const allowedFeaturePrivileges = allowedPrivileges.feature[featureId]; - if (allowedFeaturePrivileges == null) { - throw new Error('Unable to get enabled feature privileges for a feature without privileges'); - } - - return allowedFeaturePrivileges.privileges; - }; - - private getPrivilegeExplanation = (featureId: string): PrivilegeExplanation => { - const { calculatedPrivileges } = this.props; - const calculatedFeaturePrivileges = calculatedPrivileges.feature[featureId]; - if (calculatedFeaturePrivileges == null) { - throw new Error('Unable to get privilege explanation for a feature without privileges'); - } - - return calculatedFeaturePrivileges; + }, + } + ); + return columns; }; - private allowsNoneForPrivilegeAssignment = (featureId: string): boolean => { - const { allowedPrivileges } = this.props; - const allowedFeaturePrivileges = allowedPrivileges.feature[featureId]; - if (allowedFeaturePrivileges == null) { - throw new Error('Unable to determine if none is allowed for a feature without privileges'); + private toggleExpandedFeature = (featureId: string) => { + if (this.state.expandedFeatures.includes(featureId)) { + this.setState({ + expandedFeatures: this.state.expandedFeatures.filter(ef => ef !== featureId), + }); + } else { + this.setState({ + expandedFeatures: [...this.state.expandedFeatures, featureId], + }); } - - return allowedFeaturePrivileges.canUnassign; }; private onChangeAllFeaturePrivileges = (privilege: string) => { @@ -289,7 +284,4 @@ export class FeatureTable extends Component { this.props.onChangeAll([privilege]); } }; - - private isConfiguringGlobalPrivileges = () => - isGlobalPrivilegeDefinition(this.props.role.kibana[this.props.spacesIndex]); } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx new file mode 100644 index 00000000000000..8897d89a39926e --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx @@ -0,0 +1,199 @@ +/* + * 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 React from 'react'; +import { Role } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { FeatureTableExpandedRow } from './feature_table_expanded_row'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { act } from '@testing-library/react'; + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; +}; + +describe('FeatureTableExpandedRow', () => { + it('indicates sub-feature privileges are being customized if a minimal feature privilege is set', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props() + ).toMatchObject({ + disabled: false, + checked: true, + }); + }); + + it('indicates sub-feature privileges are not being customized if a primary feature privilege is set', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props() + ).toMatchObject({ + disabled: false, + checked: false, + }); + }); + + it('does not allow customizing if a primary privilege is not set', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props() + ).toMatchObject({ + disabled: true, + checked: false, + }); + }); + + it('switches to the minimal privilege when customizing privileges, including corresponding sub-feature privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + act(() => { + findTestSubject(wrapper, 'customizeSubFeaturePrivileges').simulate('click'); + }); + + expect(onChange).toHaveBeenCalledWith('with_sub_features', [ + 'minimal_read', + 'cool_read', + 'cool_toggle_2', + ]); + }); + + it('switches to the primary privilege when not customizing privileges, removing any other privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + act(() => { + findTestSubject(wrapper, 'customizeSubFeaturePrivileges').simulate('click'); + }); + + expect(onChange).toHaveBeenCalledWith('with_sub_features', ['read']); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx new file mode 100644 index 00000000000000..fb302c22694855 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx @@ -0,0 +1,95 @@ +/* + * 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 React, { useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexItem, EuiFlexGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import { SubFeatureForm } from './sub_feature_form'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { SecuredFeature } from '../../../../model'; + +interface Props { + feature: SecuredFeature; + privilegeCalculator: PrivilegeFormCalculator; + privilegeIndex: number; + selectedFeaturePrivileges: string[]; + disabled?: boolean; + onChange: (featureId: string, featurePrivileges: string[]) => void; +} + +export const FeatureTableExpandedRow = ({ + feature, + onChange, + privilegeIndex, + privilegeCalculator, + selectedFeaturePrivileges, + disabled, +}: Props) => { + const [isCustomizing, setIsCustomizing] = useState(() => { + return feature + .getMinimalFeaturePrivileges() + .some(p => selectedFeaturePrivileges.includes(p.id)); + }); + + useEffect(() => { + const hasMinimalFeaturePrivilegeSelected = feature + .getMinimalFeaturePrivileges() + .some(p => selectedFeaturePrivileges.includes(p.id)); + + if (!hasMinimalFeaturePrivilegeSelected && isCustomizing) { + setIsCustomizing(false); + } + }, [feature, isCustomizing, selectedFeaturePrivileges]); + + const onCustomizeSubFeatureChange = (e: EuiSwitchEvent) => { + onChange( + feature.id, + privilegeCalculator.updateSelectedFeaturePrivilegesForCustomization( + feature.id, + privilegeIndex, + e.target.checked + ) + ); + setIsCustomizing(e.target.checked); + }; + + return ( + + + + } + checked={isCustomizing} + onChange={onCustomizeSubFeatureChange} + data-test-subj="customizeSubFeaturePrivileges" + disabled={ + disabled || + !privilegeCalculator.canCustomizeSubFeaturePrivileges(feature.id, privilegeIndex) + } + /> + + {feature.getSubFeatures().map(subFeature => { + return ( + + onChange(feature.id, updatedPrivileges)} + selectedFeaturePrivileges={selectedFeaturePrivileges} + disabled={disabled || !isCustomizing} + /> + + ); + })} + + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx new file mode 100644 index 00000000000000..ba7eff601f4c19 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx @@ -0,0 +1,237 @@ +/* + * 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 React from 'react'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { SecuredSubFeature } from '../../../../model'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { Role } from '../../../../../../../common/model'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { SubFeatureForm } from './sub_feature_form'; +import { EuiCheckbox, EuiButtonGroup } from '@elastic/eui'; +import { act } from '@testing-library/react'; + +// Note: these tests are not concerned with the proper display of privileges, +// as that is verified by the feature_table and privilege_space_form tests. + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; +}; + +const featureId = 'with_sub_features'; +const subFeature = kibanaFeatures.find(kf => kf.id === featureId)!.subFeatures[0]; +const securedSubFeature = new SecuredSubFeature(subFeature.toRaw()); + +describe('SubFeatureForm', () => { + it('renders disabled elements when requested', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const wrapper = mountWithIntl( + + ); + + const checkboxes = wrapper.find(EuiCheckbox); + const buttonGroups = wrapper.find(EuiButtonGroup); + + expect(checkboxes.everyWhere(checkbox => checkbox.props().disabled === true)).toBe(true); + expect(buttonGroups.everyWhere(checkbox => checkbox.props().isDisabled === true)).toBe(true); + }); + + it('fires onChange when an independent privilege is selected', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const checkbox = wrapper.find('EuiCheckbox[id="with_sub_features_cool_toggle_1"] input'); + + act(() => { + checkbox.simulate('change', { target: { checked: true } }); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_toggle_1']); + }); + + it('fires onChange when an independent privilege is deselected', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['cool_toggle_1', 'cool_toggle_2'], + }, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const checkbox = wrapper.find('EuiCheckbox[id="with_sub_features_cool_toggle_1"] input'); + + act(() => { + checkbox.simulate('change', { target: { checked: false } }); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_toggle_2']); + }); + + it('fires onChange when a mutually exclusive privilege is selected', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const button = wrapper.find(EuiButtonGroup); + + act(() => { + button.props().onChange('cool_all'); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_all']); + }); + + it('fires onChange when switching between mutually exclusive options', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const button = wrapper.find(EuiButtonGroup); + + act(() => { + button.props().onChange('cool_read'); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_toggle_1', 'cool_read']); + }); + + it('fires onChange when a mutually exclusive privilege is deselected', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['cool_all'], + }, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const button = wrapper.find(EuiButtonGroup); + + act(() => { + button.props().onChange('none'); + }); + + expect(onChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx new file mode 100644 index 00000000000000..d4b6721ddad05d --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx @@ -0,0 +1,134 @@ +/* + * 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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiCheckbox, EuiButtonGroup } from '@elastic/eui'; + +import { NO_PRIVILEGE_VALUE } from '../constants'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { + SecuredSubFeature, + SubFeaturePrivilegeGroup, + SubFeaturePrivilege, +} from '../../../../model'; + +interface Props { + featureId: string; + subFeature: SecuredSubFeature; + selectedFeaturePrivileges: string[]; + privilegeCalculator: PrivilegeFormCalculator; + privilegeIndex: number; + onChange: (selectedPrivileges: string[]) => void; + disabled?: boolean; +} + +export const SubFeatureForm = (props: Props) => { + return ( + + + {props.subFeature.name} + + {props.subFeature.getPrivilegeGroups().map(renderPrivilegeGroup)} + + ); + + function renderPrivilegeGroup(privilegeGroup: SubFeaturePrivilegeGroup, index: number) { + switch (privilegeGroup.groupType) { + case 'independent': + return renderIndependentPrivilegeGroup(privilegeGroup, index); + case 'mutually_exclusive': + return renderMutuallyExclusivePrivilegeGroup(privilegeGroup, index); + default: + throw new Error(`Unsupported privilege group type: ${privilegeGroup.groupType}`); + } + } + + function renderIndependentPrivilegeGroup( + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + return ( +
+ {privilegeGroup.privileges.map((privilege: SubFeaturePrivilege) => { + const isGranted = props.privilegeCalculator.isIndependentSubFeaturePrivilegeGranted( + props.featureId, + privilege.id, + props.privilegeIndex + ); + return ( + { + const { checked } = e.target; + if (checked) { + props.onChange([...props.selectedFeaturePrivileges, privilege.id]); + } else { + props.onChange(props.selectedFeaturePrivileges.filter(sp => sp !== privilege.id)); + } + }} + checked={isGranted} + disabled={props.disabled} + compressed={true} + /> + ); + })} +
+ ); + } + + function renderMutuallyExclusivePrivilegeGroup( + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + const firstSelectedPrivilege = props.privilegeCalculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + props.featureId, + privilegeGroup, + props.privilegeIndex + ); + + const options = [ + ...privilegeGroup.privileges.map((privilege, privilegeIndex) => { + return { + id: privilege.id, + label: privilege.name, + isDisabled: props.disabled, + }; + }), + ]; + + options.push({ + id: NO_PRIVILEGE_VALUE, + label: 'None', + isDisabled: props.disabled, + }); + + return ( + { + // Deselect all privileges which belong to this mutually-exclusive group + const privilegesWithoutGroupEntries = props.selectedFeaturePrivileges.filter( + sp => !privilegeGroup.privileges.some(privilege => privilege.id === sp) + ); + // fire on-change with the newly selected privilege + if (selectedPrivilegeId === NO_PRIVILEGE_VALUE) { + props.onChange(privilegesWithoutGroupEntries); + } else { + props.onChange([...privilegesWithoutGroupEntries, selectedPrivilegeId]); + } + }} + /> + ); + } +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx new file mode 100644 index 00000000000000..316818e4deed3e --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx @@ -0,0 +1,60 @@ +/* + * 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 React from 'react'; +import { createFeature } from '../../../../__fixtures__/kibana_features'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { FeatureTableCell } from '.'; +import { SecuredFeature } from '../../../../model'; +import { EuiIcon, EuiIconTip } from '@elastic/eui'; + +describe('FeatureTableCell', () => { + it('renders an icon and feature name', () => { + const feature = createFeature({ + id: 'test-feature', + name: 'Test Feature', + }); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature "`); + expect(wrapper.find(EuiIcon).props()).toMatchObject({ + type: feature.icon, + }); + expect(wrapper.find(EuiIconTip)).toHaveLength(0); + }); + + it('renders an icon and feature name with tooltip when configured', () => { + const feature = createFeature({ + id: 'test-feature', + name: 'Test Feature', + privilegesTooltip: 'This is my awesome tooltip content', + }); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature "`); + expect( + wrapper + .find(EuiIcon) + .first() + .props() + ).toMatchObject({ + type: feature.icon, + }); + expect(wrapper.find(EuiIconTip).props().content).toMatchInlineSnapshot(` + +

+ This is my awesome tooltip content +

+
+ `); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx new file mode 100644 index 00000000000000..9e4a3a8a99b565 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx @@ -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 React from 'react'; +import { EuiText, EuiIconTip, EuiIcon, IconType } from '@elastic/eui'; +import { SecuredFeature } from '../../../../model'; + +interface Props { + feature: SecuredFeature; +} + +export const FeatureTableCell = ({ feature }: Props) => { + let tooltipElement = null; + if (feature.getPrivilegesTooltip()) { + const tooltipContent = ( + +

{feature.getPrivilegesTooltip()}

+
+ ); + tooltipElement = ( + + ); + } + + return ( + + + {feature.name} {tooltipElement} + + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/index.ts similarity index 79% rename from x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/index.ts rename to x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/index.ts index 09e449f61356f0..8f084fcc37c500 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/index.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { rawKibanaPrivileges } from './raw_kibana_privileges'; +export { FeatureTableCell } from './feature_table_cell'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts deleted file mode 100644 index 70e48dcdc37f87..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 { FeaturesPrivileges, Role } from '../../../../../../../../common/model'; - -export interface BuildRoleOpts { - spacesPrivileges?: Array<{ - spaces: string[]; - base: string[]; - feature: FeaturesPrivileges; - }>; -} - -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/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts deleted file mode 100644 index ddab7eff6835ea..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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, - }, - feature4: { - privileges: ['all', 'read'], - 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, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, -}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts deleted file mode 100644 index 0c794b68f95daf..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 { KibanaPrivileges } from '../../../../../../../../common/model'; - -export const defaultPrivilegeDefinition = new KibanaPrivileges({ - global: { - all: ['api:/*', 'ui:/*'], - read: ['ui:/feature1/foo', 'ui:/feature2/foo', 'ui:/feature3/foo/*', 'ui:/feature4/foo'], - }, - space: { - all: [ - 'api:/feature1/*', - 'ui:/feature1/*', - 'api:/feature2/*', - 'ui:/feature2/*', - 'ui:/feature3/foo', - 'ui:/feature3/foo/*', - 'ui:/feature4/foo', - ], - read: ['ui:/feature1/foo', 'ui:/feature2/foo', 'ui:/feature3/foo/bar', 'ui:/feature4/foo'], - }, - 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/*'], - }, - feature4: { - all: ['somethingObscure:/feature4/foo', 'ui:/feature4/foo'], - read: ['ui:/feature4/foo'], - }, - }, - reserved: {}, -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts deleted file mode 100644 index 2a1c42838a83d7..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -/* - * 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 { KibanaPrivileges, 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, - kibanaPrivilege: KibanaPrivileges = defaultPrivilegeDefinition -) => { - return new KibanaAllowedPrivilegesCalculator(kibanaPrivilege, role); -}; - -const buildEffectivePrivilegesCalculator = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const factory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges); - 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" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }; - - 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" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - 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, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - it(`restricts space feature privileges when global feature privileges are set`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['all'], - feature2: ['read'], - feature4: ['all'], - }, - }, - { - 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" - }, - feature4: { - privileges: ['all'], - canUnassign: false, - }, - }, - }, - ]); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts deleted file mode 100644 index cea25649c43ff3..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts +++ /dev/null @@ -1,155 +0,0 @@ -/* - * 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 { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { - areActionsFullyCovered, - compareActions, -} from '../../../../../../../common/privilege_calculator_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { - AllowedPrivilege, - CalculatedPrivilege, - PRIVILEGE_SOURCE, -} from './kibana_privilege_calculator_types'; - -export class KibanaAllowedPrivilegesCalculator { - // reference to the global privilege definition - private globalPrivilege: RoleKibanaPrivilege; - - // list of privilege actions that comprise the global base privilege - private readonly assignedGlobalBaseActions: string[]; - - constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) { - this.globalPrivilege = this.locateGlobalPrivilege(role); - this.assignedGlobalBaseActions = this.globalPrivilege.base[0] - ? kibanaPrivileges.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: RoleKibanaPrivilege, - 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.kibanaPrivileges.getGlobalPrivileges().getAllPrivileges(); - } else { - // space base privileges are restricted based on the assigned global privileges - const spacePrivileges = this.kibanaPrivileges.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.kibanaPrivileges.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]; - if (effectiveFeaturePrivilegeExplanation == null) { - throw new Error('To calculate allowed feature privileges, we need the effective privileges'); - } - - 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.kibanaPrivileges.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.kibanaPrivileges.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/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts deleted file mode 100644 index 8d30061b92c6fb..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -/* - * 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 { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { NO_PRIVILEGE_VALUE } from '../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, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const globalPrivilegeSpec = - role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || - ({ - spaces: ['*'], - base: [], - feature: {}, - } as RoleKibanaPrivilege); - - const globalActions = globalPrivilegeSpec.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) - : []; - - return new KibanaBasePrivilegeCalculator(kibanaPrivileges, 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/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts deleted file mode 100644 index 9fefea637e1683..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * 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 { KibanaPrivileges, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { compareActions } from '../../../../../../../common/privilege_calculator_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { PRIVILEGE_SOURCE, PrivilegeExplanation } from './kibana_privilege_calculator_types'; - -export class KibanaBasePrivilegeCalculator { - constructor( - private readonly kibanaPrivileges: KibanaPrivileges, - private readonly globalPrivilege: RoleKibanaPrivilege, - private readonly assignedGlobalBaseActions: string[] - ) {} - - public getMostPermissiveBasePrivilege( - privilegeSpec: RoleKibanaPrivilege, - 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.kibanaPrivileges.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/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts deleted file mode 100644 index 887fffa1b0cbcd..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts +++ /dev/null @@ -1,959 +0,0 @@ -/* - * 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 { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { NO_PRIVILEGE_VALUE } from '../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, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const globalPrivilegeSpec = - role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || - ({ - spaces: ['*'], - base: [], - feature: {}, - } as RoleKibanaPrivilege); - - const globalActions = globalPrivilegeSpec.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) - : []; - - return new KibanaBasePrivilegeCalculator(kibanaPrivileges, globalPrivilegeSpec, globalActions); -}; - -const buildEffectiveFeaturePrivilegeCalculator = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const globalPrivilegeSpec = - role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || - ({ - spaces: ['*'], - base: [], - feature: {}, - } as RoleKibanaPrivilege); - - const globalActions = globalPrivilegeSpec.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) - : []; - - const rankedFeaturePrivileges = kibanaPrivileges.getFeaturePrivileges().getAllPrivileges(); - - return new KibanaFeaturePrivilegeCalculator( - kibanaPrivileges, - globalPrivilegeSpec, - globalActions, - rankedFeaturePrivileges - ); -}; - -interface TestOpts { - only?: boolean; - role?: BuildRoleOpts; - privilegeIndex?: number; - ignoreAssigned?: boolean; - result: Record; - feature?: string; -} - -function runTest( - description: string, - { - role: roleOpts = {}, - result = {}, - privilegeIndex = 0, - ignoreAssigned = false, - only = false, - feature = 'feature1', - }: 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, - feature, - 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, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: 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, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: 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, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false, - }, - }); - - 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('feature with "all" excluded from base privileges', () => { - runTest('returns "read" when "all" assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - feature: 'feature4', - privilegeIndex: 1, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "read" when "all" assigned as the global base privilege, which does not override assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature4: ['read'], - }, - }, - ], - }, - feature: 'feature4', - privilegeIndex: 1, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the feature privilege, which is more permissive than the base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature4: ['all'], - }, - }, - ], - }, - feature: 'feature4', - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - } - ); - }); - }); - - 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/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts deleted file mode 100644 index 1ca87871aa8923..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts +++ /dev/null @@ -1,209 +0,0 @@ -/* - * 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 { - FeaturesPrivileges, - KibanaPrivileges, - RoleKibanaPrivilege, -} from '../../../../../../../common/model'; -import { areActionsFullyCovered } from '../../../../../../../common/privilege_calculator_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { - PRIVILEGE_SOURCE, - PrivilegeExplanation, - PrivilegeScenario, -} from './kibana_privilege_calculator_types'; - -export class KibanaFeaturePrivilegeCalculator { - constructor( - private readonly kibanaPrivileges: KibanaPrivileges, - private readonly globalPrivilege: RoleKibanaPrivilege, - private readonly assignedGlobalBaseActions: string[], - private readonly rankedFeaturePrivileges: FeaturesPrivileges - ) {} - - public getMostPermissiveFeaturePrivilege( - privilegeSpec: RoleKibanaPrivilege, - 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.kibanaPrivileges - .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, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: - scenario.directlyAssignedFeaturePrivilegeMorePermissiveThanBase, - ...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: RoleKibanaPrivilege, - 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) { - const actions = this.getFeatureActions( - featureId, - this.getAssignedFeaturePrivilege(privilegeSpec, featureId) - ); - const directlyAssignedFeaturePrivilegeMorePermissiveThanBase = !areActionsFullyCovered( - this.assignedGlobalBaseActions, - actions - ); - scenarios.push({ - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase, - actions, - }); - } - - 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.kibanaPrivileges.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.kibanaPrivileges.getFeaturePrivileges().getActions(featureId, privilegeId); - } - - private getAssignedFeaturePrivilege(privilegeSpec: RoleKibanaPrivilege, 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/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts deleted file mode 100644 index 4c44c077f03363..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts +++ /dev/null @@ -1,940 +0,0 @@ -/* - * 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 { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { NO_PRIVILEGE_VALUE } from '../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, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const factory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges); - return factory.getInstance(role); -}; - -interface BuildExpectedFeaturePrivilegesOption { - features: string[]; - privilegeExplanation: PrivilegeExplanation; -} - -const buildExpectedFeaturePrivileges = (options: BuildExpectedFeaturePrivilegesOption[]) => { - return { - feature: options.reduce((acc1, option) => { - return { - ...acc1, - ...option.features.reduce((acc2, featureId) => { - return { - ...acc2, - [featureId]: option.privilegeExplanation, - }; - }, {}), - }; - }, {}), - }; -}; - -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([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }, - ]), - }, - ]); - }); - - describe(`with global base privilege of "all"`, () => { - it(`calculates global feature privilege of all for features 1-3 and read for feature 4`, () => { - 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([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - it(`calculates space base and feature privilege of all for features 1-3 and read for feature 4`, () => { - 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([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - 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'], - feature4: ['read'], - }, - }, - { - spaces: ['foo'], - base: [], - feature: { - feature1: ['read'], - feature2: ['read'], - feature3: ['read'], - feature4: ['read'], - }, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false, - }, - }, - ]), - }, - ]); - }); - }); - }); - - 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([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - 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'], - feature4: ['all'], - }, - }, - { - spaces: ['foo'], - base: [], - feature: { - feature1: ['all'], - feature2: ['all'], - feature3: ['all'], - feature4: ['all'], - }, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: 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([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - it(`calculates "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([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - 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([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_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'], - feature4: ['all'], - }, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: 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" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }; - - 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" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - 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, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - it(`restricts space feature privileges when global feature privileges are set`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['all'], - feature2: ['read'], - feature4: ['all'], - }, - }, - { - 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" - }, - feature4: { - privileges: ['all'], - canUnassign: false, - }, - }, - }, - ]); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts deleted file mode 100644 index c3bf12b6aef5f4..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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 { - FeaturesPrivileges, - KibanaPrivileges, - Role, - RoleKibanaPrivilege, -} 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 kibanaPrivileges: KibanaPrivileges, - private readonly role: Role, - public readonly rankedFeaturePrivileges: FeaturesPrivileges - ) { - const globalPrivilege = this.locateGlobalPrivilege(role); - - const assignedGlobalBaseActions: string[] = globalPrivilege.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilege.base[0]) - : []; - - this.allowedPrivilegesCalculator = new KibanaAllowedPrivilegesCalculator( - kibanaPrivileges, - role - ); - - this.effectiveBasePrivilegesCalculator = new KibanaBasePrivilegeCalculator( - kibanaPrivileges, - globalPrivilege, - assignedGlobalBaseActions - ); - - this.effectiveFeaturePrivilegesCalculator = new KibanaFeaturePrivilegeCalculator( - kibanaPrivileges, - 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: RoleKibanaPrivilege, - ignoreAssigned: boolean - ): CalculatedPrivilege { - const result: CalculatedPrivilege = { - base: this.effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - privilegeSpec, - ignoreAssigned - ), - feature: {}, - reserved: privilegeSpec._reserved, - }; - - // 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.kibanaPrivileges.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/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts deleted file mode 100644 index aeaf12d02210a0..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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; - directlyAssignedFeaturePrivilegeMorePermissiveThanBase?: boolean; -} - -export interface CalculatedPrivilege { - base: PrivilegeExplanation; - feature: { - [featureId: string]: PrivilegeExplanation | undefined; - }; - reserved: undefined | string[]; -} - -export interface PrivilegeScenario { - actualPrivilegeSource: PRIVILEGE_SOURCE; - isDirectlyAssigned: boolean; - supersededPrivilege?: string; - supersededPrivilegeSource?: PRIVILEGE_SOURCE; - actions: string[]; - directlyAssignedFeaturePrivilegeMorePermissiveThanBase?: boolean; -} - -export interface AllowedPrivilege { - base: { - privileges: string[]; - canUnassign: boolean; - }; - feature: { - [featureId: string]: - | { - privileges: string[]; - canUnassign: boolean; - } - | undefined; - }; -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts deleted file mode 100644 index febdb64b93d610..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 { - FeaturesPrivileges, - KibanaPrivileges, - Role, - copyRole, -} from '../../../../../../../common/model'; -import { compareActions } from '../../../../../../../common/privilege_calculator_utils'; -import { KibanaPrivilegeCalculator } from './kibana_privilege_calculator'; - -export class KibanaPrivilegeCalculatorFactory { - /** All feature privileges, sorted from most permissive => least permissive. */ - public readonly rankedFeaturePrivileges: FeaturesPrivileges; - - constructor(private readonly kibanaPrivileges: KibanaPrivileges) { - this.rankedFeaturePrivileges = {}; - const featurePrivilegeSet = kibanaPrivileges.getFeaturePrivileges().getAllPrivileges(); - - Object.entries(featurePrivilegeSet).forEach(([featureId, privileges]) => { - this.rankedFeaturePrivileges[featureId] = privileges.sort((privilege1, privilege2) => { - const privilege1Actions = kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege1); - const privilege2Actions = kibanaPrivileges - .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.kibanaPrivileges, - roleCopy, - this.rankedFeaturePrivileges - ); - } - - private sortPrivileges(role: Role) { - role.kibana.forEach(privilege => { - privilege.base.sort((privilege1, privilege2) => { - const privilege1Actions = this.kibanaPrivileges - .getSpacesPrivileges() - .getActions(privilege1); - - const privilege2Actions = this.kibanaPrivileges - .getSpacesPrivileges() - .getActions(privilege2); - - return compareActions(privilege1Actions, privilege2Actions); - }); - - Object.entries(privilege.feature).forEach(([featureId, featurePrivs]) => { - featurePrivs.sort((privilege1, privilege2) => { - const privilege1Actions = this.kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege1); - - const privilege2Actions = this.kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege2); - - return compareActions(privilege1Actions, privilege2Actions); - }); - }); - }); - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx index 6487179b1d6e58..8fea0e02f3c8df 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx @@ -6,12 +6,13 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { KibanaPrivileges, Role } from '../../../../../../common/model'; +import { Role } from '../../../../../../common/model'; import { RoleValidator } from '../../validate_role'; import { KibanaPrivilegesRegion } from './kibana_privileges_region'; import { SimplePrivilegeSection } from './simple_privilege_section'; -import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; import { TransformErrorSection } from './transform_error_section'; +import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; +import { KibanaPrivileges } from '../../../model'; const buildProps = (customProps = {}) => { return { @@ -39,12 +40,15 @@ const buildProps = (customProps = {}) => { }, ], features: [], - kibanaPrivileges: new KibanaPrivileges({ - global: {}, - space: {}, - features: {}, - reserved: {}, - }), + kibanaPrivileges: new KibanaPrivileges( + { + global: {}, + space: {}, + features: {}, + reserved: {}, + }, + [] + ), intl: null as any, uiCapabilities: { navLinks: {}, @@ -57,6 +61,7 @@ const buildProps = (customProps = {}) => { editable: true, onChange: jest.fn(), validator: new RoleValidator(), + canCustomizeSubFeaturePrivileges: true, ...customProps, }; }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx index a4e287632c7642..284bcb29f9b6e5 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx @@ -7,21 +7,20 @@ import React, { Component } from 'react'; import { Capabilities } from 'src/core/public'; import { Space } from '../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../features/public'; -import { KibanaPrivileges, Role } from '../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from './kibana_privilege_calculator'; +import { Role } from '../../../../../../common/model'; import { RoleValidator } from '../../validate_role'; import { CollapsiblePanel } from '../../collapsible_panel'; import { SimplePrivilegeSection } from './simple_privilege_section'; import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; import { TransformErrorSection } from './transform_error_section'; +import { KibanaPrivileges } from '../../../model'; interface Props { role: Role; spacesEnabled: boolean; + canCustomizeSubFeaturePrivileges: boolean; spaces?: Space[]; uiCapabilities: Capabilities; - features: Feature[]; editable: boolean; kibanaPrivileges: KibanaPrivileges; onChange: (role: Role) => void; @@ -42,31 +41,28 @@ export class KibanaPrivilegesRegion extends Component { kibanaPrivileges, role, spacesEnabled, + canCustomizeSubFeaturePrivileges, spaces = [], uiCapabilities, onChange, editable, validator, - features, } = this.props; if (role._transform_error && role._transform_error.includes('kibana')) { return ; } - const privilegeCalculatorFactory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges); - if (spacesEnabled) { return ( ); @@ -74,11 +70,10 @@ export class KibanaPrivilegesRegion extends Component { return ( ); } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/index.ts similarity index 62% rename from x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/index.ts rename to x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/index.ts index 056a4d3022fc5c..121d615c1fc35f 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/index.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory'; -export * from './kibana_privilege_calculator_types'; +export { PrivilegeFormCalculator } from './privilege_form_calculator'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts new file mode 100644 index 00000000000000..edf2af918fd042 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts @@ -0,0 +1,833 @@ +/* + * 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 { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { Role } from '../../../../../../../common/model'; +import { PrivilegeFormCalculator } from './privilege_form_calculator'; + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'unit test role', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana, + }; +}; + +describe('PrivilegeFormCalculator', () => { + describe('#getBasePrivilege', () => { + it(`returns undefined when no base privilege is assigned`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toBeUndefined(); + }); + + it(`ignores unknown base privileges`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['unknown'], + feature: {}, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toBeUndefined(); + }); + + it(`returns the assigned base privilege`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toMatchObject({ + id: 'read', + }); + }); + + it(`returns the most permissive base privilege when multiple are assigned`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read', 'all'], + feature: {}, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toMatchObject({ + id: 'all', + }); + }); + }); + + describe('#getDisplayedPrimaryFeaturePrivilegeId', () => { + it('returns undefined when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0) + ).toBeUndefined(); + }); + + it('returns the effective privilege id when a base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual( + 'all' + ); + }); + + it('returns the most permissive assigned primary feature privilege id', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read', 'all', 'minimal_read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual( + 'all' + ); + }); + + it('returns the primary version of the minimal privilege id when assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual( + 'read' + ); + }); + }); + + describe('#hasCustomizedSubFeaturePrivileges', () => { + it('returns false when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns false when there are no sub-feature privileges assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns false when the assigned sub-features are also granted by other assigned privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'cool_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns true when the assigned sub-features are not also granted by other assigned privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns true when a minimal primary feature privilege is assigned, whose corresponding primary grants sub-feature privileges which are not assigned ', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns false when a minimal primary feature privilege is assigned, whose corresponding primary grants sub-feature privileges which are all assigned ', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns true when a minimal primary feature privilege is assigned, whose corresponding primary does not grant all assigned sub-feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [ + 'minimal_read', + 'cool_read', + 'cool_toggle_2', + 'cool_excluded_toggle', + ], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns false when a base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + }); + + describe('#getEffectivePrimaryFeaturePrivilege', () => { + it('returns undefined when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0) + ).toBeUndefined(); + }); + + it('returns the most permissive feature privilege granted by the assigned base privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'read', + }); + }); + + it('returns the most permissive feature privilege granted by the assigned feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: { + with_sub_features: ['read', 'all', 'minimal_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'all', + }); + }); + + it('prefers `read` primary over `mininal_all`', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_all', 'read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'read', + }); + }); + + it('returns the minimal primary feature privilege when assigned and not superseded', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'minimal_all', + }); + }); + + it('ignores unknown privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['unknown'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0) + ).toBeUndefined(); + }); + }); + + describe('#isIndependentSubFeaturePrivilegeGranted', () => { + it('returns false when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0) + ).toEqual(false); + }); + + it('returns false when an excluded sub-feature privilege is not directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted( + 'with_sub_features', + 'cool_excluded_toggle', + 0 + ) + ).toEqual(false); + }); + + it('returns true when an excluded sub-feature privilege is directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all', 'cool_excluded_toggle'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted( + 'with_sub_features', + 'cool_excluded_toggle', + 0 + ) + ).toEqual(true); + }); + + it('returns true when a sub-feature privilege is directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all', 'cool_toggle_1'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0) + ).toEqual(true); + }); + + it('returns true when a sub-feature privilege is inherited', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0) + ).toEqual(true); + }); + }); + + describe('#getSelectedMutuallyExclusiveSubFeaturePrivilege', () => { + it('returns undefined when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!; + const subFeatureGroup = coolSubFeature + .getPrivilegeGroups() + .find(pg => pg.groupType === 'mutually_exclusive')!; + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + 'with_sub_features', + subFeatureGroup, + 0 + ) + ).toBeUndefined(); + }); + + it('returns the inherited privilege when not directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!; + const subFeatureGroup = coolSubFeature + .getPrivilegeGroups() + .find(pg => pg.groupType === 'mutually_exclusive')!; + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + 'with_sub_features', + subFeatureGroup, + 0 + ) + ).toMatchObject({ + id: 'cool_all', + }); + }); + + it('returns the the most permissive effective privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'cool_read', 'cool_all'], + }, + spaces: ['foo'], + }, + ]); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!; + const subFeatureGroup = coolSubFeature + .getPrivilegeGroups() + .find(pg => pg.groupType === 'mutually_exclusive')!; + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + 'with_sub_features', + subFeatureGroup, + 0 + ) + ).toMatchObject({ + id: 'cool_all', + }); + }); + }); + + describe('#canCustomizeSubFeaturePrivileges', () => { + it('returns false if no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns false if a base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns true if a minimal privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns true if a primary feature privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + }); + + describe('#updateSelectedFeaturePrivilegesForCustomization', () => { + it('returns the privileges unmodified if no primary feature privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['some-privilege'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, true) + ).toEqual(['some-privilege']); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, false) + ).toEqual(['some-privilege']); + }); + + it('switches to the minimal privilege when customizing, but explicitly grants the sub-feature privileges which were originally inherited', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, true) + ).toEqual(['minimal_read', 'cool_read', 'cool_toggle_2']); + }); + + it('switches to the non-minimal privilege when customizing, removing all other privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, false) + ).toEqual(['read']); + }); + }); + + describe('#hasSupersededInheritedPrivileges', () => { + // More exhaustive testing is done at the UI layer: `privilege_space_table.test.tsx` + it('returns false for the global privilege definition', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + { + base: ['all'], + feature: { + with_sub_features: ['read'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(1)).toEqual(false); + }); + + it('returns false when the global privilege is not more permissive', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(false); + }); + + it('returns true when the global feature privilege is more permissive', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(true); + }); + + it('returns true when the global base privilege is more permissive', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(true); + }); + + it('returns false when only the global base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: {}, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts new file mode 100644 index 00000000000000..8cff37f4bd4b06 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts @@ -0,0 +1,303 @@ +/* + * 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 { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { KibanaPrivileges, SubFeaturePrivilegeGroup } from '../../../../model'; + +/** + * Calculator responsible for determining the displayed and effective privilege values for the following interfaces: + * - and children + * - and children + */ +export class PrivilegeFormCalculator { + constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) {} + + /** + * Returns the assigned base privilege. + * If more than one base privilege is assigned, the most permissive privilege will be returned. + * If no base privileges are assigned, then this will return `undefined`. + * + * @param privilegeIndex the index of the kibana privileges role component + */ + public getBasePrivilege(privilegeIndex: number) { + const entry = this.role.kibana[privilegeIndex]; + + const basePrivileges = this.kibanaPrivileges.getBasePrivileges(entry); + return basePrivileges.find(bp => entry.base.includes(bp.id)); + } + + /** + * Returns the ID of the *displayed* Primary Feature Privilege for the indicated feature and privilege index. + * If the effective primary feature privilege is a "minimal" version, then this returns the corresponding non-minimal version. + * + * @example + * The following kibana privilege entry will return `read`: + * ```ts + * const entry = { + * base: [], + * feature: { + * some_feature: ['minimal_read'], + * } + * } + * ``` + * + * @param featureId the feature id to get the Primary Feature KibanaPrivilege for. + * @param privilegeIndex the index of the kibana privileges role component + */ + public getDisplayedPrimaryFeaturePrivilegeId(featureId: string, privilegeIndex: number) { + return this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex)?.id; + } + + /** + * Determines if the indicated feature has sub-feature privilege assignments which differ from the "displayed" primary feature privilege. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + */ + public hasCustomizedSubFeaturePrivileges(featureId: string, privilegeIndex: number) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const displayedPrimary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex); + + const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + this.role.kibana[privilegeIndex], + ]); + + return feature.getSubFeaturePrivileges().some(sfp => { + const isGranted = formPrivileges.grantsPrivilege(sfp); + const isGrantedByDisplayedPrimary = displayedPrimary?.grantsPrivilege(sfp) ?? isGranted; + + return isGranted !== isGrantedByDisplayedPrimary; + }); + } + + /** + * Returns the most permissive effective Primary Feature KibanaPrivilege, including the minimal versions. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + */ + public getEffectivePrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const basePrivilege = this.getBasePrivilege(privilegeIndex); + + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + + return feature + .getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) + .find(fp => { + return selectedFeaturePrivileges.includes(fp.id) || basePrivilege?.grantsPrivilege(fp); + }); + } + + /** + * Determines if the indicated sub-feature privilege is granted. + * + * @param featureId the feature id + * @param privilegeId the sub feature privilege id + * @param privilegeIndex the index of the kibana privileges role component + */ + public isIndependentSubFeaturePrivilegeGranted( + featureId: string, + privilegeId: string, + privilegeIndex: number + ) { + const kibanaPrivilege = this.role.kibana[privilegeIndex]; + + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + const subFeaturePrivilege = feature + .getSubFeaturePrivileges() + .find(ap => ap.id === privilegeId)!; + + const assignedPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + kibanaPrivilege, + ]); + + return assignedPrivileges.grantsPrivilege(subFeaturePrivilege); + } + + /** + * Returns the most permissive effective privilege within the indicated mutually-exclusive sub feature privilege group. + * + * @param featureId the feature id + * @param subFeatureGroup the mutually-exclusive sub feature group + * @param privilegeIndex the index of the kibana privileges role component + */ + public getSelectedMutuallyExclusiveSubFeaturePrivilege( + featureId: string, + subFeatureGroup: SubFeaturePrivilegeGroup, + privilegeIndex: number + ) { + const kibanaPrivilege = this.role.kibana[privilegeIndex]; + const assignedPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + kibanaPrivilege, + ]); + + return subFeatureGroup.privileges.find(p => { + return assignedPrivileges.grantsPrivilege(p); + }); + } + + /** + * Determines if the indicated feature is capable of having its sub-feature privileges customized. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + */ + public canCustomizeSubFeaturePrivileges(featureId: string, privilegeIndex: number) { + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + return feature + .getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) + .some(apfp => selectedFeaturePrivileges.includes(apfp.id)); + } + + /** + * Returns an updated set of feature privileges based on the toggling of the "Customize sub-feature privileges" control. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + * @param willBeCustomizing flag indicating if this feature is about to have its sub-feature privileges customized or not + */ + public updateSelectedFeaturePrivilegesForCustomization( + featureId: string, + privilegeIndex: number, + willBeCustomizing: boolean + ) { + const primary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex); + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + + if (!primary) { + return selectedFeaturePrivileges; + } + + const nextPrivileges = []; + + if (willBeCustomizing) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const startingPrivileges = feature + .getSubFeaturePrivileges() + .filter(ap => primary.grantsPrivilege(ap)) + .map(p => p.id); + + nextPrivileges.push(primary.getMinimalPrivilegeId(), ...startingPrivileges); + } else { + nextPrivileges.push(primary.id); + } + + return nextPrivileges; + } + + /** + * Determines if the indicated privilege entry is less permissive than the configured "global" entry for the role. + * @param privilegeIndex the index of the kibana privileges role component + */ + public hasSupersededInheritedPrivileges(privilegeIndex: number) { + const global = this.locateGlobalPrivilege(this.role); + + const entry = this.role.kibana[privilegeIndex]; + + if (isGlobalPrivilegeDefinition(entry) || !global) { + return false; + } + + const globalPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + global, + ]); + + const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([entry]); + + const hasAssignedBasePrivileges = this.kibanaPrivileges + .getBasePrivileges(entry) + .some(base => entry.base.includes(base.id)); + + const featuresWithDirectlyAssignedPrivileges = this.kibanaPrivileges + .getSecuredFeatures() + .filter(feature => + feature + .getAllPrivileges() + .some(privilege => entry.feature[feature.id]?.includes(privilege.id)) + ); + + const hasSupersededBasePrivileges = + hasAssignedBasePrivileges && + this.kibanaPrivileges + .getBasePrivileges(entry) + .some( + privilege => + globalPrivileges.grantsPrivilege(privilege) && + !formPrivileges.grantsPrivilege(privilege) + ); + + const hasSupersededFeaturePrivileges = featuresWithDirectlyAssignedPrivileges.some(feature => + feature + .getAllPrivileges() + .some(fp => globalPrivileges.grantsPrivilege(fp) && !formPrivileges.grantsPrivilege(fp)) + ); + + return hasSupersededBasePrivileges || hasSupersededFeaturePrivileges; + } + + /** + * Returns the *displayed* Primary Feature Privilege for the indicated feature and privilege index. + * If the effective primary feature privilege is a "minimal" version, then this returns the corresponding non-minimal version. + * + * @example + * The following kibana privilege entry will return `read`: + * ```ts + * const entry = { + * base: [], + * feature: { + * some_feature: ['minimal_read'], + * } + * } + * ``` + * + * @param featureId the feature id to get the Primary Feature KibanaPrivilege for. + * @param privilegeIndex the index of the kibana privileges role component + */ + private getDisplayedPrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const basePrivilege = this.getBasePrivilege(privilegeIndex); + + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + + return feature.getPrimaryFeaturePrivileges().find(fp => { + const correspondingMinimalPrivilegeId = fp.getMinimalPrivilegeId(); + + const correspendingMinimalPrivilege = feature + .getMinimalFeaturePrivileges() + .find(mp => mp.id === correspondingMinimalPrivilegeId)!; + + // There are two cases where the minimal privileges aren't available: + // 1. The feature has no registered sub-features + // 2. Sub-feature privileges cannot be customized. When this is the case, the minimal privileges aren't registered with ES, + // so they end up represented in the UI as an empty privilege. Empty privileges cannot be granted other privileges, so if we + // encounter a minimal privilege that isn't granted by it's correspending primary, then we know we've encountered this scenario. + const hasMinimalPrivileges = + feature.subFeatures.length > 0 && fp.grantsPrivilege(correspendingMinimalPrivilege); + return ( + selectedFeaturePrivileges.includes(fp.id) || + (hasMinimalPrivileges && + selectedFeaturePrivileges.includes(correspondingMinimalPrivilegeId)) || + basePrivilege?.grantsPrivilege(fp) + ); + }); + } + + private getSelectedFeaturePrivileges(featureId: string, privilegeIndex: number) { + return this.role.kibana[privilegeIndex].feature[featureId] ?? []; + } + + private locateGlobalPrivilege(role: Role) { + return role.kibana.find(entry => isGlobalPrivilegeDefinition(entry)); + } +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts new file mode 100644 index 00000000000000..63b38b69675753 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts @@ -0,0 +1,129 @@ +/* + * 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 { ReactWrapper } from 'enzyme'; + +import { EuiTableRow } from '@elastic/eui'; + +import { findTestSubject } from 'test_utils/find_test_subject'; +import { Role, RoleKibanaPrivilege } from '../../../../../../../../common/model'; +import { PrivilegeSummaryExpandedRow } from '../privilege_summary_expanded_row'; +import { FeatureTableCell } from '../../feature_table_cell'; + +interface DisplayedFeaturePrivileges { + [featureId: string]: { + [spaceGroup: string]: { + primaryFeaturePrivilege: string; + subFeaturesPrivileges: { + [subFeatureName: string]: string[]; + }; + hasCustomizedSubFeaturePrivileges: boolean; + }; + }; +} + +const getSpaceKey = (entry: RoleKibanaPrivilege) => entry.spaces.join(', '); + +export function getDisplayedFeaturePrivileges( + wrapper: ReactWrapper, + role: Role +): DisplayedFeaturePrivileges { + const allExpanderButtons = findTestSubject(wrapper, 'expandPrivilegeSummaryRow'); + allExpanderButtons.forEach(button => button.simulate('click')); + + // each expanded row renders its own `EuiTableRow`, so there are 2 rows + // for each feature: one for the primary feature privilege, and one for the sub privilege form + const rows = wrapper.find(EuiTableRow); + + return rows.reduce((acc, row) => { + const expandedRow = row.find(PrivilegeSummaryExpandedRow); + if (expandedRow.length > 0) { + return { + ...acc, + ...getDisplayedSubFeaturePrivileges(acc, expandedRow, role), + }; + } else { + const feature = row.find(FeatureTableCell).props().feature; + + const primaryFeaturePrivileges = findTestSubject(row, 'privilegeColumn'); + + expect(primaryFeaturePrivileges).toHaveLength(role.kibana.length); + + acc[feature.id] = acc[feature.id] ?? {}; + + primaryFeaturePrivileges.forEach((primary, index) => { + const key = getSpaceKey(role.kibana[index]); + + acc[feature.id][key] = { + ...acc[feature.id][key], + primaryFeaturePrivilege: primary.text().trim(), + hasCustomizedSubFeaturePrivileges: + findTestSubject(primary, 'additionalPrivilegesGranted').length > 0, + }; + }); + + return acc; + } + }, {} as DisplayedFeaturePrivileges); +} + +function getDisplayedSubFeaturePrivileges( + displayedFeatures: DisplayedFeaturePrivileges, + expandedRow: ReactWrapper, + role: Role +) { + const { feature } = expandedRow.props(); + + const subFeatureEntries = findTestSubject(expandedRow as ReactWrapper, 'subFeatureEntry'); + + displayedFeatures[feature.id] = displayedFeatures[feature.id] ?? {}; + + subFeatureEntries.forEach(subFeatureEntry => { + const subFeatureName = findTestSubject(subFeatureEntry, 'subFeatureName').text(); + + const entryElements = findTestSubject(subFeatureEntry as ReactWrapper, 'entry', '|='); + + expect(entryElements).toHaveLength(role.kibana.length); + + role.kibana.forEach((entry, index) => { + const key = getSpaceKey(entry); + const element = findTestSubject(expandedRow as ReactWrapper, `entry-${index}`); + + const independentPrivileges = element + .find('EuiFlexGroup[data-test-subj="independentPrivilege"]') + .reduce((acc2, flexGroup) => { + const privilegeName = findTestSubject(flexGroup, 'privilegeName').text(); + const isGranted = flexGroup.exists('EuiIconTip[type="check"]'); + if (isGranted) { + return [...acc2, privilegeName]; + } + return acc2; + }, [] as string[]); + + const mutuallyExclusivePrivileges = element + .find('EuiFlexGroup[data-test-subj="mutexPrivilege"]') + .reduce((acc2, flexGroup) => { + const privilegeName = findTestSubject(flexGroup, 'privilegeName').text(); + const isGranted = flexGroup.exists('EuiIconTip[type="check"]'); + + if (isGranted) { + return [...acc2, privilegeName]; + } + return acc2; + }, [] as string[]); + + displayedFeatures[feature.id][key] = { + ...displayedFeatures[feature.id][key], + subFeaturesPrivileges: { + ...displayedFeatures[feature.id][key].subFeaturesPrivileges, + [subFeatureName]: [...independentPrivileges, ...mutuallyExclusivePrivileges], + }, + }; + }); + }); + + return displayedFeatures; +} diff --git a/x-pack/plugins/security/common/model/kibana_privileges/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/index.ts similarity index 81% rename from x-pack/plugins/security/common/model/kibana_privileges/index.ts rename to x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/index.ts index ab9baa1356c4b3..5f7dc0d99654e3 100644 --- a/x-pack/plugins/security/common/model/kibana_privileges/index.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { KibanaPrivileges } from './kibana_privileges'; +export { PrivilegeSummary } from './privilege_summary'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx new file mode 100644 index 00000000000000..85144d37ce7542 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx @@ -0,0 +1,82 @@ +/* + * 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 React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { PrivilegeSummary } from '.'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { PrivilegeSummaryTable } from './privilege_summary_table'; + +const createRole = (roleKibanaPrivileges: RoleKibanaPrivilege[]) => ({ + name: 'some-role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: roleKibanaPrivileges, +}); + +const spaces = [ + { + id: 'default', + name: 'Default Space', + disabledFeatures: [], + }, +]; + +describe('PrivilegeSummary', () => { + it('initially renders a button', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['default'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + expect(findTestSubject(wrapper, 'viewPrivilegeSummaryButton')).toHaveLength(1); + expect(wrapper.find(PrivilegeSummaryTable)).toHaveLength(0); + }); + + it('clicking the button renders the privilege summary table', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['default'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'viewPrivilegeSummaryButton').simulate('click'); + expect(wrapper.find(PrivilegeSummaryTable)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx new file mode 100644 index 00000000000000..e0889d91d759a9 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx @@ -0,0 +1,73 @@ +/* + * 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 React, { useState, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiModal, + EuiButtonEmpty, + EuiOverlayMask, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiButton, +} from '@elastic/eui'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { Role } from '../../../../../../../common/model'; +import { PrivilegeSummaryTable } from './privilege_summary_table'; +import { KibanaPrivileges } from '../../../../model'; + +interface Props { + role: Role; + spaces: Space[]; + kibanaPrivileges: KibanaPrivileges; + canCustomizeSubFeaturePrivileges: boolean; +} +export const PrivilegeSummary = (props: Props) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + setIsOpen(true)} data-test-subj="viewPrivilegeSummaryButton"> + + + {isOpen && ( + + setIsOpen(false)} maxWidth={false}> + + + + + + + + + + setIsOpen(false)}> + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts new file mode 100644 index 00000000000000..6163a6ec7ba238 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts @@ -0,0 +1,338 @@ +/* + * 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 { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { PrivilegeSummaryCalculator } from './privilege_summary_calculator'; + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'unit test role', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana, + }; +}; +describe('PrivilegeSummaryCalculator', () => { + describe('#getEffectiveFeaturePrivileges', () => { + it('returns an empty privilege set when nothing is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + }); + }); + + it('calculates effective privileges when inherited from the global privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: {}, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'], + }, + }); + }); + + it('calculates effective privileges when there are non-superseded sub-feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['cool_excluded_toggle'], + }, + spaces: ['foo'], + }, + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: true, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [ + 'cool_all', + 'cool_read', + 'cool_toggle_1', + 'cool_toggle_2', + 'cool_excluded_toggle', + ], + }, + }); + }); + + it('calculates privileges for all features for a space entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: { + excluded_from_base: ['all'], + no_sub_features: ['read'], + with_excluded_sub_features: ['all'], + with_sub_features: ['minimal_read', 'cool_excluded_toggle'], + }, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: true, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [ + 'cool_all', + 'cool_read', + 'cool_toggle_1', + 'cool_toggle_2', + 'cool_excluded_toggle', + ], + }, + }); + }); + + it('calculates privileges for all features for a global entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'], + }, + }); + }); + + it('calculates privileges for a single feature at a space entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_excluded_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + }); + }); + + it('calculates privileges for a single feature at the global entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_excluded_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts new file mode 100644 index 00000000000000..27ed8c443045ad --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts @@ -0,0 +1,109 @@ +/* + * 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, RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { KibanaPrivileges, PrimaryFeaturePrivilege, SecuredFeature } from '../../../../model'; +import { PrivilegeCollection } from '../../../../model/privilege_collection'; + +export interface EffectiveFeaturePrivileges { + [featureId: string]: { + primary?: PrimaryFeaturePrivilege; + subFeature: string[]; + hasCustomizedSubFeaturePrivileges: boolean; + }; +} +export class PrivilegeSummaryCalculator { + constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) {} + + public getEffectiveFeaturePrivileges(entry: RoleKibanaPrivilege): EffectiveFeaturePrivileges { + const assignedPrivileges = this.collectAssignedPrivileges(entry); + + const features = this.kibanaPrivileges.getSecuredFeatures(); + + return features.reduce((acc, feature) => { + const displayedPrimaryFeaturePrivilege = this.getDisplayedPrimaryFeaturePrivilege( + assignedPrivileges, + feature + ); + + const effectiveSubPrivileges = feature + .getSubFeaturePrivileges() + .filter(ap => assignedPrivileges.grantsPrivilege(ap)); + + const hasCustomizedSubFeaturePrivileges = this.hasCustomizedSubFeaturePrivileges( + feature, + displayedPrimaryFeaturePrivilege, + entry + ); + + return { + ...acc, + [feature.id]: { + primary: displayedPrimaryFeaturePrivilege, + hasCustomizedSubFeaturePrivileges, + subFeature: effectiveSubPrivileges.map(p => p.id), + }, + }; + }, {} as EffectiveFeaturePrivileges); + } + + private hasCustomizedSubFeaturePrivileges( + feature: SecuredFeature, + displayedPrimaryFeaturePrivilege: PrimaryFeaturePrivilege | undefined, + entry: RoleKibanaPrivilege + ) { + const formPrivileges = this.collectAssignedPrivileges(entry); + + return feature.getSubFeaturePrivileges().some(sfp => { + const isGranted = formPrivileges.grantsPrivilege(sfp); + const isGrantedByDisplayedPrimary = + displayedPrimaryFeaturePrivilege?.grantsPrivilege(sfp) ?? isGranted; + + // if displayed primary is derived from base, then excluded sub-feature-privs should not count. + return isGranted !== isGrantedByDisplayedPrimary; + }); + } + + private getDisplayedPrimaryFeaturePrivilege( + assignedPrivileges: PrivilegeCollection, + feature: SecuredFeature + ) { + const primaryFeaturePrivileges = feature.getPrimaryFeaturePrivileges(); + const minimalPrimaryFeaturePrivileges = feature.getMinimalFeaturePrivileges(); + + const hasMinimalPrivileges = feature.subFeatures.length > 0; + + const effectivePrivilege = primaryFeaturePrivileges.find(pfp => { + const isPrimaryGranted = assignedPrivileges.grantsPrivilege(pfp); + if (!isPrimaryGranted && hasMinimalPrivileges) { + const correspondingMinimal = minimalPrimaryFeaturePrivileges.find( + mpfp => mpfp.id === pfp.getMinimalPrivilegeId() + )!; + + return assignedPrivileges.grantsPrivilege(correspondingMinimal); + } + return isPrimaryGranted; + }); + + return effectivePrivilege; + } + + private collectAssignedPrivileges(entry: RoleKibanaPrivilege) { + if (isGlobalPrivilegeDefinition(entry)) { + return this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([entry]); + } + + const globalPrivilege = this.locateGlobalPrivilege(this.role); + return this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges( + globalPrivilege ? [globalPrivilege, entry] : [entry] + ); + } + + private locateGlobalPrivilege(role: Role) { + return role.kibana.find(entry => isGlobalPrivilegeDefinition(entry)); + } +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx new file mode 100644 index 00000000000000..3283f7a58a27c2 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx @@ -0,0 +1,131 @@ +/* + * 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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIconTip } from '@elastic/eui'; +import { SecuredFeature, SubFeaturePrivilegeGroup, SubFeaturePrivilege } from '../../../../model'; +import { EffectiveFeaturePrivileges } from './privilege_summary_calculator'; + +interface Props { + feature: SecuredFeature; + effectiveFeaturePrivileges: Array; +} + +export const PrivilegeSummaryExpandedRow = (props: Props) => { + return ( + + {props.feature.getSubFeatures().map(subFeature => { + return ( + + + + + {subFeature.name} + + + {props.effectiveFeaturePrivileges.map((privs, index) => { + return ( + + {subFeature.getPrivilegeGroups().map(renderPrivilegeGroup(privs.subFeature))} + + ); + })} + + + ); + })} + + ); + + function renderPrivilegeGroup(effectiveSubFeaturePrivileges: string[]) { + return (privilegeGroup: SubFeaturePrivilegeGroup, index: number) => { + switch (privilegeGroup.groupType) { + case 'independent': + return renderIndependentPrivilegeGroup( + effectiveSubFeaturePrivileges, + privilegeGroup, + index + ); + case 'mutually_exclusive': + return renderMutuallyExclusivePrivilegeGroup( + effectiveSubFeaturePrivileges, + privilegeGroup, + index + ); + default: + throw new Error(`Unsupported privilege group type: ${privilegeGroup.groupType}`); + } + }; + } + + function renderIndependentPrivilegeGroup( + effectiveSubFeaturePrivileges: string[], + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + return ( +
+ {privilegeGroup.privileges.map((privilege: SubFeaturePrivilege) => { + const isGranted = effectiveSubFeaturePrivileges.includes(privilege.id); + return ( + + + + + + + {privilege.name} + + + + ); + })} +
+ ); + } + + function renderMutuallyExclusivePrivilegeGroup( + effectiveSubFeaturePrivileges: string[], + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + const firstSelectedPrivilege = privilegeGroup.privileges.find(p => + effectiveSubFeaturePrivileges.includes(p.id) + )?.name; + + return ( + + + + + + + {firstSelectedPrivilege ?? 'None'} + + + + ); + } +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx new file mode 100644 index 00000000000000..0498f099b536b0 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx @@ -0,0 +1,922 @@ +/* + * 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 React from 'react'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { PrivilegeSummaryTable } from './privilege_summary_table'; +import { RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { getDisplayedFeaturePrivileges } from './__fixtures__'; + +const createRole = (roleKibanaPrivileges: RoleKibanaPrivilege[]) => ({ + name: 'some-role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: roleKibanaPrivileges, +}); + +const spaces = [ + { + id: 'default', + name: 'Default Space', + disabledFeatures: [], + }, + { + id: 'space-1', + name: 'First Space', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Second Space', + disabledFeatures: [], + }, +]; + +const maybeExpectSubFeaturePrivileges = (expect: boolean, subFeaturesPrivileges: unknown) => { + return expect ? { subFeaturesPrivileges } : {}; +}; + +const expectNoPrivileges = (displayedPrivileges: any, expectSubFeatures: boolean) => { + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(expectSubFeatures, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(expectSubFeatures, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(expectSubFeatures, { + 'Cool Sub Feature': [], + }), + }, + }, + }); +}; + +describe('PrivilegeSummaryTable', () => { + [true, false].forEach(allowSubFeaturePrivileges => { + describe(`when sub feature privileges are ${ + allowSubFeaturePrivileges ? 'allowed' : 'disallowed' + }`, () => { + it('ignores unknown base privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['idk_what_this_means'], + feature: {}, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges); + }); + + it('ignores unknown feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['this_doesnt_exist_either'], + }, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges); + }); + + it('ignores unknown features', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + unknown_feature: ['this_doesnt_exist_either'], + }, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges); + }); + + it('renders effective privileges for the global base privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for a global feature privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for the space base privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for a space feature privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + }, + with_excluded_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + }); + }); + + it('renders effective privileges for global base + space base privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: ['all'], + feature: {}, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for global base + space feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for global feature + space base privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + spaces: ['*'], + }, + { + base: ['read'], + feature: {}, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['All'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for global feature + space feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['All'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for a complex setup', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: ['read', 'all'], + feature: {}, + spaces: ['default'], + }, + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + with_excluded_sub_features: ['all', 'cool_toggle_1'], + no_sub_features: ['all'], + excluded_from_base: ['minimal_all', 'cool_toggle_1'], + }, + spaces: ['space-1', 'space-2'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'All' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2'], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': ['Cool toggle 1'], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + }, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx new file mode 100644 index 00000000000000..e04ca36b6d1938 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx @@ -0,0 +1,174 @@ +/* + * 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 React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiInMemoryTable, + EuiBasicTableColumn, + EuiButtonIcon, + EuiIcon, + EuiIconTip, +} from '@elastic/eui'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { FeatureTableCell } from '../feature_table_cell'; +import { SpaceColumnHeader } from './space_column_header'; +import { PrivilegeSummaryExpandedRow } from './privilege_summary_expanded_row'; +import { SecuredFeature, KibanaPrivileges } from '../../../../model'; +import { + PrivilegeSummaryCalculator, + EffectiveFeaturePrivileges, +} from './privilege_summary_calculator'; + +interface Props { + role: Role; + spaces: Space[]; + kibanaPrivileges: KibanaPrivileges; + canCustomizeSubFeaturePrivileges: boolean; +} + +function getColumnKey(entry: RoleKibanaPrivilege) { + return `privilege_entry_${entry.spaces.join('|')}`; +} + +export const PrivilegeSummaryTable = (props: Props) => { + const [expandedFeatures, setExpandedFeatures] = useState([]); + + const calculator = new PrivilegeSummaryCalculator(props.kibanaPrivileges, props.role); + + const toggleExpandedFeature = (featureId: string) => { + if (expandedFeatures.includes(featureId)) { + setExpandedFeatures(expandedFeatures.filter(ef => ef !== featureId)); + } else { + setExpandedFeatures([...expandedFeatures, featureId]); + } + }; + + const featureColumn: EuiBasicTableColumn = { + name: 'Feature', + field: 'feature', + render: (feature: any) => { + return ; + }, + }; + const rowExpanderColumn: EuiBasicTableColumn = { + align: 'right', + width: '40px', + isExpander: true, + field: 'featureId', + name: '', + render: (featureId: string, record: any) => { + const feature = record.feature as SecuredFeature; + const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0; + if (!hasSubFeaturePrivileges) { + return null; + } + return ( + toggleExpandedFeature(featureId)} + data-test-subj={`expandPrivilegeSummaryRow`} + aria-label={expandedFeatures.includes(featureId) ? 'Collapse' : 'Expand'} + iconType={expandedFeatures.includes(featureId) ? 'arrowUp' : 'arrowDown'} + /> + ); + }, + }; + + const rawKibanaPrivileges = [...props.role.kibana].sort((entry1, entry2) => { + if (isGlobalPrivilegeDefinition(entry1)) { + return -1; + } + if (isGlobalPrivilegeDefinition(entry2)) { + return 1; + } + return 0; + }); + const privilegeColumns = rawKibanaPrivileges.map(entry => { + const key = getColumnKey(entry); + return { + name: , + field: key, + render: (kibanaPrivilege: EffectiveFeaturePrivileges, record: { featureId: string }) => { + const { primary, hasCustomizedSubFeaturePrivileges } = kibanaPrivilege[record.featureId]; + let iconTip = null; + if (hasCustomizedSubFeaturePrivileges) { + iconTip = ( + + + + } + /> + ); + } else { + iconTip = ; + } + return ( + + {primary?.name ?? 'None'} {iconTip} + + ); + }, + }; + }); + + const columns: Array> = []; + if (props.canCustomizeSubFeaturePrivileges) { + columns.push(rowExpanderColumn); + } + columns.push(featureColumn, ...privilegeColumns); + + const privileges = rawKibanaPrivileges.reduce((acc, entry) => { + return { + ...acc, + [getColumnKey(entry)]: calculator.getEffectiveFeaturePrivileges(entry), + }; + }, {} as Record); + + const items = props.kibanaPrivileges.getSecuredFeatures().map(feature => { + return { + feature, + featureId: feature.id, + ...privileges, + }; + }); + + return ( + { + return { + 'data-test-subj': `summaryTableRow-${record.featureId}`, + }; + }} + itemIdToExpandedRowMap={expandedFeatures.reduce((acc, featureId) => { + return { + ...acc, + [featureId]: ( + p[featureId])} + /> + ), + }; + }, {})} + /> + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx new file mode 100644 index 00000000000000..b6910565284983 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx @@ -0,0 +1,123 @@ +/* + * 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 React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { SpaceColumnHeader } from './space_column_header'; +import { SpacesPopoverList } from '../../../spaces_popover_list'; +import { SpaceAvatar } from '../../../../../../../../spaces/public'; + +const spaces = [ + { + id: '*', + name: 'Global', + disabledFeatures: [], + }, + { + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Space 2', + disabledFeatures: [], + }, + { + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + }, + { + id: 'space-4', + name: 'Space 4', + disabledFeatures: [], + }, + { + id: 'space-5', + name: 'Space 5', + disabledFeatures: [], + }, +]; + +describe('SpaceColumnHeader', () => { + it('renders the Global privilege definition with a special label and popover control', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(1); + // Snapshot includes space avatar (The first "G"), followed by the "Global" label, + // followed by the (all spaces) text as part of the SpacesPopoverList + expect(wrapper.text()).toMatchInlineSnapshot(`"G Global(all spaces)"`); + }); + + it('renders a placeholder space when the requested space no longer exists', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(0); + + const avatars = wrapper.find(SpaceAvatar); + expect(avatars).toHaveLength(3); + + expect(wrapper.text()).toMatchInlineSnapshot(`"S1 m S3 "`); + }); + + it('renders a space privilege definition with an avatar for each space in the group', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(0); + + const avatars = wrapper.find(SpaceAvatar); + expect(avatars).toHaveLength(4); + + expect(wrapper.text()).toMatchInlineSnapshot(`"S1 S2 S3 S4 "`); + }); + + it('renders a space privilege definition with an avatar for the first 4 spaces in the group, with the popover control showing the rest', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(1); + + const avatars = wrapper.find(SpaceAvatar); + expect(avatars).toHaveLength(4); + + expect(wrapper.text()).toMatchInlineSnapshot(`"S1 S2 S3 S4 +1 more"`); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx new file mode 100644 index 00000000000000..8ed9bb449b595a --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx @@ -0,0 +1,78 @@ +/* + * 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 React, { Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { Space, SpaceAvatar } from '../../../../../../../../spaces/public'; +import { RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { SpacesPopoverList } from '../../../spaces_popover_list'; + +interface Props { + spaces: Space[]; + entry: RoleKibanaPrivilege; +} + +const SPACES_DISPLAY_COUNT = 4; + +export const SpaceColumnHeader = (props: Props) => { + const isGlobal = isGlobalPrivilegeDefinition(props.entry); + const entrySpaces = props.entry.spaces.map(spaceId => { + return ( + props.spaces.find(s => s.id === spaceId) ?? { + id: spaceId, + name: spaceId, + disabledFeatures: [], + } + ); + }); + return ( +
+ {entrySpaces.slice(0, SPACES_DISPLAY_COUNT).map(space => { + return ( + + {' '} + {isGlobal && ( + + +
+ s.id !== '*')} + buttonText={i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink', + { + defaultMessage: '(all spaces)', + } + )} + /> +
+ )} +
+ ); + })} + {entrySpaces.length > SPACES_DISPLAY_COUNT && ( + +
+ +
+ )} +
+ ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap index 4d8f590f286aef..7873e47d2e0ff3 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap @@ -2,153 +2,159 @@ exports[` renders without crashing 1`] = ` - - -

- } - title={ -

- -

- } + - - + +

+ +

+
+ + + - + hasChildLabel={true} + hasEmptyLabelSpace={false} + label={ + + } + labelType="label" + > + + + + +

+ +

+ , + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "none", - }, - Object { - "dropdownDisplay": - + , + "value": "none", + }, + Object { + "dropdownDisplay": + + + +

+ +

+
, + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "custom", - }, - Object { - "dropdownDisplay": - + , + "value": "custom", + }, + Object { + "dropdownDisplay": + + + +

+ +

+
, + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "read", - }, - Object { - "dropdownDisplay": - + , + "value": "read", + }, + Object { + "dropdownDisplay": + + + +

+ +

+
, + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "all", - }, - ] - } - valueOfSelected="none" - /> -
-
+ , + "value": "all", + }, + ] + } + valueOfSelected="none" + /> + + +
`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx index db1e3cfd616212..7ecf32ee45b857 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx @@ -7,24 +7,53 @@ import { EuiButtonGroup, EuiButtonGroupProps, EuiComboBox, EuiSuperSelect } from '@elastic/eui'; import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role } from '../../../../../../../common/model'; import { SimplePrivilegeSection } from './simple_privilege_section'; import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning'; +import { KibanaPrivileges, SecuredFeature } from '../../../../model'; const buildProps = (customProps: any = {}) => { - const kibanaPrivileges = new KibanaPrivileges({ - features: { - feature1: { - all: ['*'], - read: ['read'], + const features = [ + new SecuredFeature({ + id: 'feature1', + name: 'Feature 1', + app: ['app'], + icon: 'spacesApp', + privileges: { + all: { + app: ['app'], + savedObject: { + all: ['foo'], + read: [], + }, + ui: ['app-ui'], + }, + read: { + app: ['app'], + savedObject: { + all: [], + read: [], + }, + ui: ['app-ui'], + }, }, + }), + ] as SecuredFeature[]; + + const kibanaPrivileges = new KibanaPrivileges( + { + features: { + feature1: { + all: ['*'], + read: ['read'], + }, + }, + global: {}, + space: {}, + reserved: {}, }, - global: {}, - space: {}, - reserved: {}, - }); + features + ); const role = { name: '', @@ -40,34 +69,9 @@ const buildProps = (customProps: any = {}) => { return { editable: true, kibanaPrivileges, - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory(kibanaPrivileges), - features: [ - { - id: 'feature1', - name: 'Feature 1', - app: ['app'], - icon: 'spacesApp', - privileges: { - all: { - app: ['app'], - savedObject: { - all: ['foo'], - read: [], - }, - ui: ['app-ui'], - }, - read: { - app: ['app'], - savedObject: { - all: [], - read: [], - }, - ui: ['app-ui'], - }, - }, - }, - ] as Feature[], + features, onChange: jest.fn(), + canCustomizeSubFeaturePrivileges: true, ...customProps, role, }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx index 2221fc6bab2797..d68d43e8089c77 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx @@ -6,34 +6,28 @@ import { EuiComboBox, - EuiDescribedFormGroup, EuiFormRow, EuiSuperSelect, EuiText, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment } from 'react'; - -import { Feature } from '../../../../../../../../features/public'; -import { - KibanaPrivileges, - Role, - RoleKibanaPrivilege, - copyRole, -} from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role, RoleKibanaPrivilege, copyRole } from '../../../../../../../common/model'; import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; import { CUSTOM_PRIVILEGE_VALUE, NO_PRIVILEGE_VALUE } from '../constants'; import { FeatureTable } from '../feature_table'; import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning'; +import { KibanaPrivileges } from '../../../../model'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; interface Props { role: Role; kibanaPrivileges: KibanaPrivileges; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; - features: Feature[]; onChange: (role: Role) => void; editable: boolean; + canCustomizeSubFeaturePrivileges: boolean; } interface State { @@ -58,20 +52,14 @@ export class SimplePrivilegeSection extends Component { public render() { const kibanaPrivilege = this.getDisplayedBasePrivilege(); - const privilegeCalculator = this.props.privilegeCalculatorFactory.getInstance(this.props.role); - - const calculatedPrivileges = privilegeCalculator.calculateEffectivePrivileges()[ - this.state.globalPrivsIndex - ]; - - const allowedPrivileges = privilegeCalculator.calculateAllowedPrivileges()[ - this.state.globalPrivsIndex - ]; + const reservedPrivileges = this.props.role.kibana[this.state.globalPrivsIndex]?._reserved ?? []; - const hasReservedPrivileges = - calculatedPrivileges && - calculatedPrivileges.reserved != null && - calculatedPrivileges.reserved.length > 0; + const title = ( + + ); const description = (

@@ -84,162 +72,159 @@ export class SimplePrivilegeSection extends Component { return ( - - - - } - description={description} - > - - {hasReservedPrivileges ? ( - ({ - label: privilege, - }))} - isDisabled - /> - ) : ( - - - - ), - dropdownDisplay: ( - - + + + + {description} + + + + + {reservedPrivileges.length > 0 ? ( + ({ label: rp }))} + isDisabled + /> + ) : ( + - -

- -

- - ), - }, - { - value: CUSTOM_PRIVILEGE_VALUE, - inputDisplay: ( - - - - ), - dropdownDisplay: ( - - + + ), + dropdownDisplay: ( + + + + +

+ +

+
+ ), + }, + { + value: CUSTOM_PRIVILEGE_VALUE, + inputDisplay: ( + -
-

+ + ), + dropdownDisplay: ( + + + + +

+ +

+ + ), + }, + { + value: 'read', + inputDisplay: ( + -

-
- ), - }, - { - value: 'read', - inputDisplay: ( - - - - ), - dropdownDisplay: ( - - - - -

- -

-
- ), - }, - { - value: 'all', - inputDisplay: ( - - - - ), - dropdownDisplay: ( - - + + ), + dropdownDisplay: ( + + + + +

+ +

+
+ ), + }, + { + value: 'all', + inputDisplay: ( + -
-

- -

- - ), - }, - ]} - hasDividers - valueOfSelected={kibanaPrivilege} - /> - )} - - {this.state.isCustomizingGlobalPrivilege && ( - - isGlobalPrivilegeDefinition(k))} - /> + + ), + dropdownDisplay: ( + + + + +

+ +

+
+ ), + }, + ]} + hasDividers + valueOfSelected={kibanaPrivilege} + /> + )}
- )} - {this.maybeRenderSpacePrivilegeWarning()} - + {this.state.isCustomizingGlobalPrivilege && ( + + + isGlobalPrivilegeDefinition(k) + )} + canCustomizeSubFeaturePrivileges={this.props.canCustomizeSubFeaturePrivileges} + /> + + )} + {this.maybeRenderSpacePrivilegeWarning()} + + ); } @@ -295,7 +280,7 @@ export class SimplePrivilegeSection extends Component { const form = this.locateGlobalPrivilege(role) || this.createGlobalPrivilegeEntry(role); if (privileges.length > 0) { - this.props.features.forEach(feature => { + this.props.kibanaPrivileges.getSecuredFeatures().forEach(feature => { form.feature[feature.id] = [...privileges]; }); } else { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts deleted file mode 100644 index 428836c9f181b9..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 { RawKibanaPrivileges } from '../../../../../../../../common/model'; - -export const rawKibanaPrivileges: RawKibanaPrivileges = { - global: { - all: [ - 'normal-feature-all', - 'normal-feature-read', - 'just-global-all', - 'all-privilege-excluded-from-base-read', - ], - read: ['normal-feature-read', 'all-privilege-excluded-from-base-read'], - }, - space: { - all: ['normal-feature-all', 'normal-feature-read', 'all-privilege-excluded-from-base-read'], - read: ['normal-feature-read', 'all-privilege-excluded-from-base-read'], - }, - reserved: {}, - features: { - normal: { - all: ['normal-feature-all', 'normal-feature-read'], - read: ['normal-feature-read'], - }, - bothPrivilegesExcludedFromBase: { - all: ['both-privileges-excluded-from-base-all', 'both-privileges-excluded-from-base-read'], - read: ['both-privileges-excluded-from-base-read'], - }, - allPrivilegeExcludedFromBase: { - all: ['all-privilege-excluded-from-base-all', 'all-privilege-excluded-from-base-read'], - read: ['all-privilege-excluded-from-base-read'], - }, - }, -}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap deleted file mode 100644 index a3fbdebee7eba6..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap +++ /dev/null @@ -1,118 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PrivilegeDisplay renders a superceded privilege 1`] = ` - -`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap deleted file mode 100644 index 8d10e27df9694e..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap +++ /dev/null @@ -1,497 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders without crashing 1`] = ` - - - - -

- -

-
-
- - - - - - - - - - -

- -

- , - "inputDisplay": - - , - "value": "basePrivilege_custom", - }, - Object { - "disabled": false, - "dropdownDisplay": - - - -

- -

-
, - "inputDisplay": - - , - "value": "basePrivilege_read", - }, - Object { - "dropdownDisplay": - - - -

- -

-
, - "inputDisplay": - - , - "value": "basePrivilege_all", - }, - ] - } - valueOfSelected="basePrivilege_custom" - /> -
- - -

- Customize by feature -

-
- - -

- Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege. -

-
- - -
-
- - - - - - - - - - - - - - -
-
-`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx index c6268e19abfd1f..155ccf98b97626 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiIconTip, EuiText, EuiToolTip } from '@elastic/eui'; +import { EuiText } from '@elastic/eui'; import React from 'react'; -import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { PRIVILEGE_SOURCE } from '../kibana_privilege_calculator'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { PrivilegeDisplay } from './privilege_display'; describe('PrivilegeDisplay', () => { @@ -23,41 +22,4 @@ describe('PrivilegeDisplay', () => { color: 'danger', }); }); - - it('renders a privilege with tooltip, if provided', () => { - const wrapper = mountWithIntl( - ahh} /> - ); - expect(wrapper.text().trim()).toEqual('All'); - expect(wrapper.find(EuiToolTip).props()).toMatchObject({ - content: ahh, - }); - }); - - it('renders a privilege with icon tooltip, if provided', () => { - const wrapper = mountWithIntl( - ahh} iconType={'asterisk'} /> - ); - expect(wrapper.text().trim()).toEqual('All'); - expect(wrapper.find(EuiIconTip).props()).toMatchObject({ - type: 'asterisk', - content: ahh, - }); - }); - - it('renders a superceded privilege', () => { - const wrapper = shallowWithIntl( - - ); - expect(wrapper).toMatchSnapshot(); - }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx index 55ac99da4c8c16..93f1d9bba460d9 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx @@ -3,95 +3,28 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon, EuiIconTip, EuiText, IconType, PropsOf, EuiToolTip } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiIcon, EuiText, PropsOf } from '@elastic/eui'; import _ from 'lodash'; import React, { ReactNode, FC } from 'react'; -import { PRIVILEGE_SOURCE, PrivilegeExplanation } from '../kibana_privilege_calculator'; import { NO_PRIVILEGE_VALUE } from '../constants'; interface Props extends PropsOf { privilege: string | string[] | undefined; - explanation?: PrivilegeExplanation; - iconType?: IconType; - iconTooltipContent?: ReactNode; - tooltipContent?: ReactNode; + 'data-test-subj'?: string; } export const PrivilegeDisplay: FC = (props: Props) => { - const { explanation } = props; - - if (!explanation) { - return ; - } - - if (explanation.supersededPrivilege) { - return ; - } - - if (!explanation.isDirectlyAssigned) { - return ; - } - return ; }; const SimplePrivilegeDisplay: FC = (props: Props) => { - const { privilege, iconType, iconTooltipContent, explanation, tooltipContent, ...rest } = props; - - const text = ( - - {getDisplayValue(privilege)} {getIconTip(iconType, iconTooltipContent)} - - ); + const { privilege, ...rest } = props; - if (tooltipContent) { - return {text}; - } + const text = {getDisplayValue(privilege)}; return text; }; -export const SupersededPrivilegeDisplay: FC = (props: Props) => { - const { supersededPrivilege, actualPrivilegeSource } = - props.explanation || ({} as PrivilegeExplanation); - - return ( - - } - /> - ); -}; - -export const EffectivePrivilegeDisplay: FC = (props: Props) => { - const { explanation, ...rest } = props; - - const source = getReadablePrivilegeSource(explanation!.actualPrivilegeSource); - - const iconTooltipContent = ( - - ); - - return ( - - ); -}; - PrivilegeDisplay.defaultProps = { privilege: [], }; @@ -113,24 +46,6 @@ function getDisplayValue(privilege: string | string[] | undefined) { return displayValue; } -function getIconTip(iconType?: IconType, tooltipContent?: ReactNode) { - if (!iconType || !tooltipContent) { - return null; - } - - return ( - - ); -} - function coerceToArray(privilege: string | string[] | undefined): string[] { if (privilege === undefined) { return []; @@ -140,43 +55,3 @@ function coerceToArray(privilege: string | string[] | undefined): string[] { } return [privilege]; } - -function getReadablePrivilegeSource(privilegeSource: PRIVILEGE_SOURCE) { - switch (privilegeSource) { - case PRIVILEGE_SOURCE.GLOBAL_BASE: - return ( - - ); - case PRIVILEGE_SOURCE.GLOBAL_FEATURE: - return ( - - ); - case PRIVILEGE_SOURCE.SPACE_BASE: - return ( - - ); - case PRIVILEGE_SOURCE.SPACE_FEATURE: - return ( - - ); - default: - return ( - - ); - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx deleted file mode 100644 index a01c026c1a5df9..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* - * 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 { EuiButtonEmpty, EuiInMemoryTable } from '@elastic/eui'; -import React from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { Space } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; -import { PrivilegeMatrix } from './privilege_matrix'; - -describe('PrivilegeMatrix', () => { - it('can render a complex matrix', () => { - const spaces: Space[] = ['*', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'].map(a => ({ - id: a, - name: `${a} space`, - disabledFeatures: [], - })); - - const features: Feature[] = [ - { - id: 'feature1', - name: 'feature 1', - icon: 'apmApp', - app: [], - privileges: {}, - }, - { - id: 'feature2', - name: 'feature 2', - icon: 'apmApp', - app: [], - privileges: {}, - }, - { - id: 'feature3', - name: 'feature 3', - icon: 'apmApp', - app: [], - privileges: {}, - }, - ]; - - const role: Role = { - name: 'role', - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], - base: [], - feature: { - feature2: ['read'], - feature3: ['all'], - }, - }, - { - spaces: ['k'], - base: ['all'], - feature: { - feature2: ['read'], - feature3: ['read'], - }, - }, - ], - }; - - const calculator = new KibanaPrivilegeCalculatorFactory( - new KibanaPrivileges({ - global: { - all: [], - read: [], - }, - features: { - feature1: { - all: [], - read: [], - }, - feature2: { - all: [], - read: [], - }, - feature3: { - all: [], - read: [], - }, - }, - space: { - all: [], - read: [], - }, - reserved: {}, - }) - ).getInstance(role); - - const wrapper = mountWithIntl( - - ); - - wrapper.find(EuiButtonEmpty).simulate('click'); - wrapper.update(); - - const { columns, items } = wrapper.find(EuiInMemoryTable).props() as any; - - expect(columns).toHaveLength(4); // all spaces groups plus the "feature" column - expect(items).toHaveLength(features.length + 1); // all features plus the "base" row - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx deleted file mode 100644 index f0f425273e25d4..00000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx +++ /dev/null @@ -1,342 +0,0 @@ -/* - * 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 { - EuiButton, - EuiButtonEmpty, - EuiIcon, - EuiIconTip, - EuiInMemoryTable, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiOverlayMask, - IconType, -} from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; -import React, { Component, Fragment } from 'react'; -import { Space, SpaceAvatar } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { FeaturesPrivileges, Role } from '../../../../../../../common/model'; -import { CalculatedPrivilege } from '../kibana_privilege_calculator'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { SpacesPopoverList } from '../../../spaces_popover_list'; -import { PrivilegeDisplay } from './privilege_display'; - -const SPACES_DISPLAY_COUNT = 4; - -interface Props { - role: Role; - spaces: Space[]; - features: Feature[]; - calculatedPrivileges: CalculatedPrivilege[]; - intl: InjectedIntl; -} - -interface State { - showModal: boolean; -} - -interface TableRow { - feature: Feature & { isBase: boolean }; - tooltip?: string; - role: Role; -} - -interface SpacesColumn { - isGlobal: boolean; - spacesIndex: number; - spaces: Space[]; - privileges: { - base: string[]; - feature: FeaturesPrivileges; - }; -} - -export class PrivilegeMatrix extends Component { - public state = { - showModal: false, - }; - public render() { - let modal = null; - if (this.state.showModal) { - modal = ( - - - - - - - - {this.renderTable()} - - - - - - - - ); - } - - return ( - - - - - {modal} - - ); - } - - private renderTable = () => { - const { role, features, intl } = this.props; - - const spacePrivileges = role.kibana; - - const globalPrivilege = this.locateGlobalPrivilege(); - - const spacesColumns: SpacesColumn[] = []; - - spacePrivileges.forEach((spacePrivs, spacesIndex) => { - spacesColumns.push({ - isGlobal: isGlobalPrivilegeDefinition(spacePrivs), - spacesIndex, - spaces: spacePrivs.spaces - .map(spaceId => this.props.spaces.find(space => space.id === spaceId)) - .filter(Boolean) as Space[], - privileges: { - base: spacePrivs.base, - feature: spacePrivs.feature, - }, - }); - }); - - const rows: TableRow[] = [ - { - feature: { - id: '*base*', - isBase: true, - name: intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeText', - defaultMessage: 'Base privilege', - }), - app: [], - privileges: {}, - }, - role, - }, - ...features.map(feature => ({ - feature: { - ...feature, - isBase: false, - }, - role, - })), - ]; - - const columns = [ - { - field: 'feature', - name: intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeMatrix.featureColumnTitle', - defaultMessage: 'Feature', - }), - width: '230px', - render: (feature: Feature & { isBase: boolean }) => { - return feature.isBase ? ( - - {feature.name} - - - ) : ( - - {feature.icon && ( - - )} - {feature.name} - - ); - }, - }, - ...spacesColumns.map(item => { - let columnWidth; - if (item.isGlobal) { - columnWidth = '100px'; - } else if (item.spaces.length - SPACES_DISPLAY_COUNT) { - columnWidth = '90px'; - } else { - columnWidth = '80px'; - } - - return { - // TODO: this is a hacky way to determine if we are looking at the global feature - // used for cellProps below... - field: item.isGlobal ? 'global' : 'feature', - width: columnWidth, - name: ( -
- {item.spaces.slice(0, SPACES_DISPLAY_COUNT).map((space: Space) => ( - - {' '} - {item.isGlobal && ( - - -
- s.id !== '*')} - intl={this.props.intl} - buttonText={this.props.intl.formatMessage({ - id: - 'xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink', - defaultMessage: '(all spaces)', - })} - /> -
- )} -
- ))} - {item.spaces.length > SPACES_DISPLAY_COUNT && ( - -
- -
- )} -
- ), - render: (feature: Feature & { isBase: boolean }, record: TableRow) => { - return this.renderPrivilegeDisplay(item, record, globalPrivilege.base); - }, - }; - }), - ]; - - return ( - { - return { - className: item.feature.isBase ? 'secPrivilegeMatrix__row--isBasePrivilege' : '', - }; - }} - cellProps={(item: TableRow, column: Record) => { - return { - className: - column.field === 'global' ? 'secPrivilegeMatrix__cell--isGlobalPrivilege' : '', - }; - }} - /> - ); - }; - - private renderPrivilegeDisplay = ( - column: SpacesColumn, - { feature }: TableRow, - globalBasePrivilege: string[] - ) => { - if (column.isGlobal) { - if (feature.isBase) { - return ; - } - - const featureCalculatedPrivilege = this.props.calculatedPrivileges[column.spacesIndex] - .feature[feature.id]; - - return ( - - ); - } else { - // not global - - const calculatedPrivilege = this.props.calculatedPrivileges[column.spacesIndex]; - - if (feature.isBase) { - // Space base privilege - const actualBasePrivileges = calculatedPrivilege.base.actualPrivilege; - - return ( - - ); - } - - const featurePrivilegeExplanation = calculatedPrivilege.feature[feature.id]; - - return ( - - ); - } - }; - - private locateGlobalPrivilege = () => { - return ( - this.props.role.kibana.find(spacePriv => isGlobalPrivilegeDefinition(spacePriv)) || { - spaces: ['*'], - base: [], - feature: [], - } - ); - }; - - private hideModal = () => { - this.setState({ - showModal: false, - }); - }; - - private showModal = () => { - this.setState({ - showModal: true, - }); - }; -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx index 675f02a81f9e1d..968730181fe104 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx @@ -4,123 +4,379 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { merge } from 'lodash'; -// @ts-ignore -import { findTestSubject } from '@elastic/eui/lib/test'; -import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; -import { KibanaPrivileges } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { PrivilegeSpaceForm } from './privilege_space_form'; -import { rawKibanaPrivileges } from './__fixtures__'; +import React from 'react'; +import { Space } from '../../../../../../../../spaces/public'; +import { EuiSuperSelect } from '@elastic/eui'; +import { FeatureTable } from '../feature_table'; +import { getDisplayedFeaturePrivileges } from '../feature_table/__fixtures__'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { SpaceSelector } from './space_selector'; -type RecursivePartial = { - [P in keyof T]?: RecursivePartial; +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; }; -const buildProps = ( - overrides?: RecursivePartial -): PrivilegeSpaceForm['props'] => { - const kibanaPrivileges = new KibanaPrivileges(rawKibanaPrivileges); - const defaultProps: PrivilegeSpaceForm['props'] = { - spaces: [ +const displaySpaces: Space[] = [ + { + id: 'foo', + name: 'Foo Space', + disabledFeatures: [], + }, + { + id: 'default', + name: 'Default Space', + disabledFeatures: [], + }, + { + id: '*', + name: 'Global', + disabledFeatures: [], + }, +]; + +describe('PrivilegeSpaceForm', () => { + it('renders an empty form when the role contains no Kibana privileges', () => { + const role = createRole(); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_custom`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(true); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "none", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); + }); + + it('renders when a base privilege is selected', () => { + const role = createRole([ { - id: 'default', - name: 'Default Space', - description: '', - disabledFeatures: [], - _reserved: true, + base: ['all'], + feature: {}, + spaces: ['foo'], }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_all`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(true); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "all", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "all", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "all", + "subFeaturePrivileges": Array [ + "with_sub_features_cool_toggle_1", + "with_sub_features_cool_toggle_2", + "cool_all", + ], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); + }); + + it('renders when a feature privileges are selected', () => { + const role = createRole([ { - id: 'marketing', - name: 'Marketing', - description: '', - disabledFeatures: [], + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], }, - ], - kibanaPrivileges, - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory(kibanaPrivileges), - features: [], - role: { - name: 'test role', - elasticsearch: { - cluster: ['all'], - indices: [] as any[], - run_as: [] as string[], + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_custom`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(false); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "none", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "read", + "subFeaturePrivileges": Array [ + "with_sub_features_cool_toggle_2", + "cool_read", + ], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); + }); + + it('renders a warning when configuring a global privilege after space privileges are already defined', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], }, - kibana: [{ spaces: [], base: [], feature: {} }], - }, - onChange: jest.fn(), - onCancel: jest.fn(), - intl: {} as any, - editingIndex: 0, - }; - return merge(defaultProps, overrides || {}); -}; + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + wrapper + .find(SpaceSelector) + .props() + .onChange(['*']); + + wrapper.update(); -describe('', () => { - it('renders without crashing', () => { - expect(shallowWithIntl()).toMatchSnapshot(); + expect(findTestSubject(wrapper, 'globalPrivilegeWarning')).toHaveLength(1); }); - it(`defaults to "Custom" for new global entries`, () => { - const props = buildProps({ - role: { - kibana: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - ], + it('renders a warning when space privileges are less permissive than configured global privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], }, - editingIndex: 0, - }); - const component = mountWithIntl(); - const basePrivilegeComboBox = findTestSubject(component, `basePrivilegeComboBox`); - expect(basePrivilegeComboBox.text()).toBe('Custom'); + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_custom`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(false); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "none", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "read", + "subFeaturePrivileges": Array [ + "with_sub_features_cool_toggle_2", + "cool_read", + ], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(1); + expect(findTestSubject(wrapper, 'globalPrivilegeWarning')).toHaveLength(0); }); - it(`defaults to "Custom" for new space entries`, () => { - const props = buildProps({ - role: { - kibana: [ - { - spaces: ['space:default'], - base: [], - feature: {}, - }, - ], + it('allows all feature privileges to be changed via "change all"', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], }, - editingIndex: 0, - }); - const component = mountWithIntl(); - const basePrivilegeComboBox = findTestSubject(component, `basePrivilegeComboBox`); - expect(basePrivilegeComboBox.text()).toBe('Custom'); + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['read'], + with_excluded_sub_features: ['read'], + no_sub_features: ['read'], + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); }); - describe('when an existing global all privilege', () => { - it(`defaults to "Custom" for new entries`, () => { - const props = buildProps({ - role: { - kibana: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['default'], - base: [], - feature: {}, - }, - ], + it('passes the `canCustomizeSubFeaturePrivileges` prop to the FeatureTable', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], }, - editingIndex: 1, - }); - const component = mountWithIntl(); - const basePrivilegeComboBox = findTestSubject(component, `basePrivilegeComboBox`); - expect(basePrivilegeComboBox.text()).toBe('Custom'); - }); + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const onChange = jest.fn(); + + const canCustomize = (Symbol('can customize') as unknown) as boolean; + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(FeatureTable).props().canCustomizeSubFeaturePrivileges).toBe(canCustomize); }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx index 6f841b5d14cb36..4e9e02bb531f12 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx @@ -21,46 +21,42 @@ import { EuiSuperSelect, EuiText, EuiTitle, + EuiErrorBoundary, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment } from 'react'; import { Space } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role, copyRole } from '../../../../../../../common/model'; -import { - AllowedPrivilege, - KibanaPrivilegeCalculatorFactory, - PrivilegeExplanation, -} from '../kibana_privilege_calculator'; -import { hasAssignedFeaturePrivileges } from '../../../privilege_utils'; -import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; -import { FeatureTable } from '../feature_table'; +import { Role, copyRole } from '../../../../../../../common/model'; import { SpaceSelector } from './space_selector'; +import { FeatureTable } from '../feature_table'; +import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { KibanaPrivileges } from '../../../../model'; interface Props { role: Role; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; kibanaPrivileges: KibanaPrivileges; - features: Feature[]; spaces: Space[]; - editingIndex: number; + privilegeIndex: number; + canCustomizeSubFeaturePrivileges: boolean; onChange: (role: Role) => void; onCancel: () => void; - intl: InjectedIntl; } interface State { - editingIndex: number; + privilegeIndex: number; selectedSpaceIds: string[]; selectedBasePrivilege: string[]; role: Role; mode: 'create' | 'update'; isCustomizingFeaturePrivileges: boolean; + privilegeCalculator: PrivilegeFormCalculator; } export class PrivilegeSpaceForm extends Component { public static defaultProps = { - editingIndex: -1, + privilegeIndex: -1, }; constructor(props: Props) { @@ -68,10 +64,10 @@ export class PrivilegeSpaceForm extends Component { const role = copyRole(props.role); - let editingIndex = props.editingIndex; - if (editingIndex < 0) { + let privilegeIndex = props.privilegeIndex; + if (privilegeIndex < 0) { // create new form - editingIndex = + privilegeIndex = role.kibana.push({ spaces: [], base: [], @@ -81,11 +77,12 @@ export class PrivilegeSpaceForm extends Component { this.state = { role, - editingIndex, - selectedSpaceIds: [...role.kibana[editingIndex].spaces], - selectedBasePrivilege: [...(role.kibana[editingIndex].base || [])], - mode: props.editingIndex < 0 ? 'create' : 'update', + privilegeIndex, + selectedSpaceIds: [...role.kibana[privilegeIndex].spaces], + selectedBasePrivilege: [...(role.kibana[privilegeIndex].base || [])], + mode: props.privilegeIndex < 0 ? 'create' : 'update', isCustomizingFeaturePrivileges: false, + privilegeCalculator: new PrivilegeFormCalculator(props.kibanaPrivileges, role), }; } @@ -103,8 +100,33 @@ export class PrivilegeSpaceForm extends Component { - {this.getForm()} + + {this.getForm()} + + {this.state.privilegeCalculator.hasSupersededInheritedPrivileges( + this.state.privilegeIndex + ) && ( + + + } + > + + + + + )} { data-test-subj={'cancelSpacePrivilegeButton'} > @@ -128,18 +150,7 @@ export class PrivilegeSpaceForm extends Component { } private getForm = () => { - const { intl, spaces, privilegeCalculatorFactory } = this.props; - - const privilegeCalculator = privilegeCalculatorFactory.getInstance(this.state.role); - - const calculatedPrivileges = privilegeCalculator.calculateEffectivePrivileges()[ - this.state.editingIndex - ]; - const allowedPrivileges = privilegeCalculator.calculateAllowedPrivileges()[ - this.state.editingIndex - ]; - - const baseExplanation = calculatedPrivileges.base; + const { spaces } = this.props; const hasSelectedSpaces = this.state.selectedSpaceIds.length > 0; @@ -147,16 +158,17 @@ export class PrivilegeSpaceForm extends Component { @@ -164,10 +176,12 @@ export class PrivilegeSpaceForm extends Component { { options={[ { value: 'basePrivilege_custom', - disabled: !this.canCustomizeFeaturePrivileges(baseExplanation, allowedPrivileges), inputDisplay: ( { }, { value: 'basePrivilege_read', - disabled: !allowedPrivileges.base.privileges.includes('read'), inputDisplay: ( { }, ]} hasDividers - valueOfSelected={this.getDisplayedBasePrivilege(allowedPrivileges, baseExplanation)} + valueOfSelected={this.getDisplayedBasePrivilege()} disabled={!hasSelectedSpaces} /> @@ -280,14 +292,12 @@ export class PrivilegeSpaceForm extends Component { 0 || !hasSelectedSpaces} /> @@ -297,6 +307,7 @@ export class PrivilegeSpaceForm extends Component { { private getFeatureListLabel = (disabled: boolean) => { if (disabled) { - return this.props.intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges', - defaultMessage: 'Summary of feature privileges', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges', + { + defaultMessage: 'Summary of feature privileges', + } + ); } else { - return this.props.intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivileges', - defaultMessage: 'Customize by feature', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivileges', + { + defaultMessage: 'Customize by feature', + } + ); } }; private getFeatureListDescription = (disabled: boolean) => { if (disabled) { - return this.props.intl.formatMessage({ - id: - 'xpack.security.management.editRole.spacePrivilegeForm.featurePrivilegeSummaryDescription', - defaultMessage: - 'Some features might be hidden by the space or affected by a global space privilege.', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.featurePrivilegeSummaryDescription', + { + defaultMessage: + 'Some features might be hidden by the space or affected by a global space privilege.', + } + ); } else { - return this.props.intl.formatMessage({ - id: - 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivilegeDescription', - defaultMessage: - 'Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege.', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivilegeDescription', + { + defaultMessage: + 'Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege.', + } + ); } }; @@ -410,10 +427,12 @@ export class PrivilegeSpaceForm extends Component { ); @@ -429,7 +448,7 @@ export class PrivilegeSpaceForm extends Component { private onSaveClick = () => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; // remove any spaces that no longer exist if (!this.isDefiningGlobalPrivilege()) { @@ -444,18 +463,19 @@ export class PrivilegeSpaceForm extends Component { private onSelectedSpacesChange = (selectedSpaceIds: string[]) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; form.spaces = [...selectedSpaceIds]; this.setState({ selectedSpaceIds, role, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; private onSpaceBasePrivilegeChange = (basePrivilege: string) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; const privilegeName = basePrivilege.split('basePrivilege_')[1]; @@ -473,47 +493,25 @@ export class PrivilegeSpaceForm extends Component { selectedBasePrivilege: privilegeName === CUSTOM_PRIVILEGE_VALUE ? [] : [privilegeName], role, isCustomizingFeaturePrivileges, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; - private getDisplayedBasePrivilege = ( - allowedPrivileges: AllowedPrivilege, - explanation: PrivilegeExplanation - ) => { - let displayedBasePrivilege = explanation.actualPrivilege; - - if (this.canCustomizeFeaturePrivileges(explanation, allowedPrivileges)) { - const form = this.state.role.kibana[this.state.editingIndex]; - - if ( - hasAssignedFeaturePrivileges(form) || - form.base.length === 0 || - this.state.isCustomizingFeaturePrivileges - ) { - displayedBasePrivilege = CUSTOM_PRIVILEGE_VALUE; - } - } - - return displayedBasePrivilege ? `basePrivilege_${displayedBasePrivilege}` : undefined; - }; + private getDisplayedBasePrivilege = () => { + const basePrivilege = this.state.privilegeCalculator.getBasePrivilege( + this.state.privilegeIndex + ); - private canCustomizeFeaturePrivileges = ( - basePrivilegeExplanation: PrivilegeExplanation, - allowedPrivileges: AllowedPrivilege - ) => { - if (basePrivilegeExplanation.isDirectlyAssigned) { - return true; + if (basePrivilege) { + return `basePrivilege_${basePrivilege.id}`; } - const featureEntries = Object.values(allowedPrivileges.feature); - return featureEntries.some(entry => { - return entry != null && (entry.canUnassign || entry.privileges.length > 1); - }); + return `basePrivilege_${CUSTOM_PRIVILEGE_VALUE}`; }; private onFeaturePrivilegesChange = (featureId: string, privileges: string[]) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; if (privileges.length === 0) { delete form.feature[featureId]; @@ -523,32 +521,29 @@ export class PrivilegeSpaceForm extends Component { this.setState({ role, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; private onChangeAllFeaturePrivileges = (privileges: string[]) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; - - const calculator = this.props.privilegeCalculatorFactory.getInstance(role); - const allowedPrivs = calculator.calculateAllowedPrivileges(); + const entry = role.kibana[this.state.privilegeIndex]; if (privileges.length === 0) { - form.feature = {}; + entry.feature = {}; } else { - this.props.features.forEach(feature => { - const allowedPrivilegesFeature = allowedPrivs[this.state.editingIndex].feature[feature.id]; - const canAssign = - allowedPrivilegesFeature && allowedPrivilegesFeature.privileges.includes(privileges[0]); - - if (canAssign) { - form.feature[feature.id] = [...privileges]; + this.props.kibanaPrivileges.getSecuredFeatures().forEach(feature => { + const nextFeaturePrivilege = feature + .getPrimaryFeaturePrivileges() + .find(pfp => privileges.includes(pfp.id)); + if (nextFeaturePrivilege) { + entry.feature[feature.id] = [nextFeaturePrivilege.id]; } }); } - this.setState({ role, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; @@ -557,7 +552,7 @@ export class PrivilegeSpaceForm extends Component { return false; } - const form = this.state.role.kibana[this.state.editingIndex]; + const form = this.state.role.kibana[this.state.privilegeIndex]; if (form.base.length === 0 && Object.keys(form.feature).length === 0) { return false; } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx index f0a391c98c9100..b1c7cb4b631e6d 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx @@ -5,14 +5,16 @@ */ import React from 'react'; -import { EuiBadge, EuiInMemoryTable, EuiIconTip } from '@elastic/eui'; +import { EuiBadge, EuiInMemoryTable } from '@elastic/eui'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { PrivilegeSpaceTable } from './privilege_space_table'; import { PrivilegeDisplay } from './privilege_display'; -import { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; -import { rawKibanaPrivileges } from './__fixtures__'; +import { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { Feature } from '../../../../../../../../features/public'; +import { findTestSubject } from 'test_utils/find_test_subject'; interface TableRow { spaces: string[]; @@ -21,20 +23,125 @@ interface TableRow { }; } -const buildProps = (roleKibanaPrivileges: RoleKibanaPrivilege[]): PrivilegeSpaceTable['props'] => { - const kibanaPrivileges = new KibanaPrivileges(rawKibanaPrivileges); - return { - role: { - name: 'test role', - elasticsearch: { - cluster: ['all'], - indices: [] as any[], - run_as: [] as string[], +const features = [ + new Feature({ + id: 'normal', + name: 'normal feature', + app: [], + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-all', 'normal-feature-read'], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-read'], }, - kibana: roleKibanaPrivileges, }, - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory(kibanaPrivileges), - onChange: (role: Role) => {}, + }), + new Feature({ + id: 'normal_with_sub', + name: 'normal feature with sub features', + app: [], + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-all', 'normal-feature-read'], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-read'], + }, + }, + subFeatures: [ + { + name: 'sub feature', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'normal_sub_all', + name: 'normal sub feature privilege', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['normal-sub-all', 'normal-sub-read'], + }, + { + id: 'normal_sub_read', + name: 'normal sub feature read privilege', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['normal-sub-read'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'excluded_sub_priv', + name: 'excluded sub feature privilege', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['excluded-sub-priv'], + }, + ], + }, + ], + }, + ], + }), + new Feature({ + id: 'bothPrivilegesExcludedFromBase', + name: 'bothPrivilegesExcludedFromBase', + app: [], + privileges: { + all: { + excludeFromBasePrivileges: true, + savedObject: { all: [], read: [] }, + ui: ['both-privileges-excluded-from-base-all', 'both-privileges-excluded-from-base-read'], + }, + read: { + excludeFromBasePrivileges: true, + savedObject: { all: [], read: [] }, + ui: ['both-privileges-excluded-from-base-read'], + }, + }, + }), + new Feature({ + id: 'allPrivilegeExcludedFromBase', + name: 'allPrivilegeExcludedFromBase', + app: [], + privileges: { + all: { + excludeFromBasePrivileges: true, + savedObject: { all: [], read: [] }, + ui: ['all-privilege-excluded-from-base-all', 'all-privilege-excluded-from-base-read'], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['all-privilege-excluded-from-base-read'], + }, + }, + }), +]; + +const buildProps = (roleKibanaPrivileges: RoleKibanaPrivilege[]): PrivilegeSpaceTable['props'] => { + const kibanaPrivileges = createKibanaPrivileges(features); + const role = { + name: 'test role', + elasticsearch: { + cluster: ['all'], + indices: [] as any[], + run_as: [] as string[], + }, + kibana: roleKibanaPrivileges, + }; + return { + role, + privilegeCalculator: new PrivilegeFormCalculator(kibanaPrivileges, role), + onChange: (r: Role) => {}, onEdit: (spacesIndex: number) => {}, displaySpaces: [ { @@ -51,7 +158,6 @@ const buildProps = (roleKibanaPrivileges: RoleKibanaPrivilege[]): PrivilegeSpace disabledFeatures: [], }, ], - intl: {} as any, }; }; @@ -73,7 +179,9 @@ const getTableFromComponent = ( spaces: spacesBadge.map(badge => badge.text().trim()), privileges: { summary: privilegesDisplay.text().trim(), - overridden: privilegesDisplay.find(EuiIconTip).exists('[type="lock"]'), + overridden: + findTestSubject(row as ReactWrapper, 'spaceTablePrivilegeSupersededWarning') + .length > 0, }, }, ]; @@ -117,6 +225,28 @@ describe('only global', () => { ]); }); + it('normal feature privilege minimal_all', () => { + const props = buildProps([ + { spaces: ['*'], base: [], feature: { normal_with_sub: ['minimal_all'] } }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + + it('normal feature privilege minimal_all and normal_sub_read', () => { + const props = buildProps([ + { spaces: ['*'], base: [], feature: { normal_with_sub: ['minimal_all', 'normal_sub_read'] } }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + it('bothPrivilegesExcludedFromBase feature privilege all', () => { const props = buildProps([ { spaces: ['*'], base: [], feature: { bothPrivilegesExcludedFromBase: ['read'] } }, @@ -203,6 +333,32 @@ describe('only default and marketing space', () => { ]); }); + it('normal feature privilege minimal_all', () => { + const props = buildProps([ + { spaces: ['default', 'marketing'], base: [], feature: { normal_with_sub: ['minimal_all'] } }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + + it('normal feature privilege minimal_all and normal_sub_read', () => { + const props = buildProps([ + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_all', 'normal_sub_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + it('bothPrivilegesExcludedFromBase feature privilege all', () => { const props = buildProps([ { @@ -275,7 +431,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: false } }, ]); }); @@ -288,7 +444,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, ]); }); @@ -301,7 +457,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); @@ -314,7 +470,41 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, + ]); + }); + + it('normal feature privilege minimal_all', () => { + const props = buildProps([ + { spaces: ['*'], base: ['all'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_all'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, + ]); + }); + + it('normal feature privilege minimal_all and normal_sub_read', () => { + const props = buildProps([ + { spaces: ['*'], base: ['all'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_all', 'normal_sub_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); @@ -382,7 +572,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); }); @@ -412,7 +602,7 @@ describe('global base read', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: false } }, ]); }); @@ -438,7 +628,41 @@ describe('global base read', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + + it('normal feature privilege minimal_read', () => { + const props = buildProps([ + { spaces: ['*'], base: ['read'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, + ]); + }); + + it('normal feature privilege minimal_read and normal_sub_read', () => { + const props = buildProps([ + { spaces: ['*'], base: ['read'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_read', 'normal_sub_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); @@ -506,7 +730,7 @@ describe('global base read', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); }); @@ -562,7 +786,7 @@ describe('global normal feature privilege all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); @@ -844,7 +1068,7 @@ describe('global bothPrivilegesExcludedFromBase feature privilege all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); @@ -1126,7 +1350,7 @@ describe('global allPrivilegeExcludedFromBase feature privilege all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); }); @@ -1213,6 +1437,7 @@ describe('global allPrivilegeExcludedFromBase feature privilege read', () => { }, ]); const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx index 1a43fb9e2683a1..ccb5398a11b236 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx @@ -10,35 +10,32 @@ import { EuiButtonIcon, EuiInMemoryTable, EuiBasicTableColumn, + EuiIcon, + EuiIconTip, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import _ from 'lodash'; import React, { Component } from 'react'; import { Space, getSpaceColor } from '../../../../../../../../spaces/public'; -import { - FeaturesPrivileges, - Role, - RoleKibanaPrivilege, - copyRole, -} from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; -import { - isGlobalPrivilegeDefinition, - hasAssignedFeaturePrivileges, -} from '../../../privilege_utils'; -import { CUSTOM_PRIVILEGE_VALUE, NO_PRIVILEGE_VALUE } from '../constants'; +import { FeaturesPrivileges, Role, copyRole } from '../../../../../../../common/model'; import { SpacesPopoverList } from '../../../spaces_popover_list'; import { PrivilegeDisplay } from './privilege_display'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; const SPACES_DISPLAY_COUNT = 4; interface Props { role: Role; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; + privilegeCalculator: PrivilegeFormCalculator; onChange: (role: Role) => void; - onEdit: (spacesIndex: number) => void; + onEdit: (privilegeIndex: number) => void; displaySpaces: Space[]; disabled?: boolean; - intl: InjectedIntl; } interface State { @@ -52,12 +49,13 @@ type TableSpace = Space & interface TableRow { spaces: TableSpace[]; - spacesIndex: number; + privilegeIndex: number; isGlobal: boolean; privileges: { spaces: string[]; base: string[]; feature: FeaturesPrivileges; + reserved: string[]; }; } @@ -71,15 +69,11 @@ export class PrivilegeSpaceTable extends Component { } private renderKibanaPrivileges = () => { - const { privilegeCalculatorFactory, displaySpaces, intl } = this.props; + const { privilegeCalculator, displaySpaces } = this.props; const spacePrivileges = this.getSortedPrivileges(); - const privilegeCalculator = privilegeCalculatorFactory.getInstance(this.props.role); - - const effectivePrivileges = privilegeCalculator.calculateEffectivePrivileges(false); - - const rows: TableRow[] = spacePrivileges.map((spacePrivs, spacesIndex) => { + const rows: TableRow[] = spacePrivileges.map((spacePrivs, privilegeIndex) => { const spaces = spacePrivs.spaces.map( spaceId => displaySpaces.find(space => space.id === spaceId) || { @@ -92,12 +86,13 @@ export class PrivilegeSpaceTable extends Component { return { spaces, - spacesIndex, + privilegeIndex, isGlobal: isGlobalPrivilegeDefinition(spacePrivs), privileges: { spaces: spacePrivs.spaces, base: spacePrivs.base || [], feature: spacePrivs.feature || {}, + reserved: spacePrivs._reserved || [], }, }; }); @@ -117,26 +112,27 @@ export class PrivilegeSpaceTable extends Component { name: 'Spaces', width: '60%', render: (spaces: TableSpace[], record: TableRow) => { - const isExpanded = this.state.expandedSpacesGroups.includes(record.spacesIndex); + const isExpanded = this.state.expandedSpacesGroups.includes(record.privilegeIndex); const displayedSpaces = isExpanded ? spaces : spaces.slice(0, SPACES_DISPLAY_COUNT); let button = null; if (record.isGlobal) { button = ( s.id !== '*')} + buttonText={i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeTable.showAllSpacesLink', + { + defaultMessage: 'show spaces', + } + )} /> ); } else if (spaces.length > displayedSpaces.length) { button = ( this.toggleExpandSpacesGroup(record.spacesIndex)} + onClick={() => this.toggleExpandSpacesGroup(record.privilegeIndex)} > { button = ( this.toggleExpandSpacesGroup(record.spacesIndex)} + onClick={() => this.toggleExpandSpacesGroup(record.privilegeIndex)} > { return (
- {displayedSpaces.map((space: TableSpace) => ( - - {space.name} - - ))} + + {displayedSpaces.map((space: TableSpace) => ( + + {space.name} + + ))} + + {button}
); @@ -178,45 +177,48 @@ export class PrivilegeSpaceTable extends Component { { field: 'privileges', name: 'Privileges', - render: (privileges: RoleKibanaPrivilege, record: TableRow) => { - const effectivePrivilege = effectivePrivileges[record.spacesIndex]; - const basePrivilege = effectivePrivilege.base; - - if (effectivePrivilege.reserved != null && effectivePrivilege.reserved.length > 0) { - return ; - } else if (record.isGlobal) { + render: (privileges: TableRow['privileges'], record: TableRow) => { + if (privileges.reserved.length > 0) { return ( ); - } else { - const hasNonSupersededCustomizations = Object.keys(privileges.feature).some( - featureId => { - const featureEffectivePrivilege = effectivePrivilege.feature[featureId]; - return ( - featureEffectivePrivilege && - featureEffectivePrivilege.directlyAssignedFeaturePrivilegeMorePermissiveThanBase - ); - } - ); - - const showCustom = - hasNonSupersededCustomizations || - (hasAssignedFeaturePrivileges(privileges) && - effectivePrivilege.base.actualPrivilege === NO_PRIVILEGE_VALUE); + } - return ( - + let icon = ; + if (privilegeCalculator.hasSupersededInheritedPrivileges(record.privilegeIndex)) { + icon = ( + + + } + /> + ); } + + return ( + + {icon} + + + + + ); }, }, ]; @@ -229,19 +231,16 @@ export class PrivilegeSpaceTable extends Component { render: (record: TableRow) => { return ( s.name).join(', '), + values: { spaceNames: record.spaces.map(s => s.name).join(', ') }, } )} color={'primary'} iconType={'pencil'} - onClick={() => this.props.onEdit(record.spacesIndex)} + onClick={() => this.props.onEdit(record.privilegeIndex)} /> ); }, @@ -250,14 +249,11 @@ export class PrivilegeSpaceTable extends Component { render: (record: TableRow) => { return ( s.name).join(', '), + values: { spaceNames: record.spaces.map(s => s.name).join(', ') }, } )} color={'danger'} @@ -294,26 +290,26 @@ export class PrivilegeSpaceTable extends Component { }); }; - private toggleExpandSpacesGroup = (spacesIndex: number) => { - if (this.state.expandedSpacesGroups.includes(spacesIndex)) { + private toggleExpandSpacesGroup = (privilegeIndex: number) => { + if (this.state.expandedSpacesGroups.includes(privilegeIndex)) { this.setState({ - expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== spacesIndex), + expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== privilegeIndex), }); } else { this.setState({ - expandedSpacesGroups: [...this.state.expandedSpacesGroups, spacesIndex], + expandedSpacesGroups: [...this.state.expandedSpacesGroups, privilegeIndex], }); } }; private onDeleteSpacePrivilege = (item: TableRow) => { const roleCopy = copyRole(this.props.role); - roleCopy.kibana.splice(item.spacesIndex, 1); + roleCopy.kibana.splice(item.privilegeIndex, 1); this.props.onChange(roleCopy); this.setState({ - expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== item.spacesIndex), + expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== item.privilegeIndex), }); }; } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx index e06d2a4f7dc337..a9bcb5433fcc71 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx @@ -6,13 +6,13 @@ import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { KibanaPrivileges } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; import { RoleValidator } from '../../../validate_role'; -import { PrivilegeMatrix } from './privilege_matrix'; import { PrivilegeSpaceForm } from './privilege_space_form'; import { PrivilegeSpaceTable } from './privilege_space_table'; import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; +import { PrivilegeSummary } from '../privilege_summary'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; const buildProps = (customProps: any = {}) => { return { @@ -42,23 +42,12 @@ const buildProps = (customProps: any = {}) => { manage: true, }, }, - features: [], + features: kibanaFeatures, editable: true, onChange: jest.fn(), validator: new RoleValidator(), - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory( - new KibanaPrivileges({ - features: { - feature1: { - all: ['*'], - read: ['read'], - }, - }, - global: {}, - space: {}, - reserved: {}, - }) - ), + kibanaPrivileges: createKibanaPrivileges(kibanaFeatures), + canCustomizeSubFeaturePrivileges: true, ...customProps, }; }; @@ -80,7 +69,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(1); @@ -89,13 +78,13 @@ describe('', () => { it('hides the space table if there are no existing space privileges', () => { const props = buildProps(); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(0); }); - it('Renders flyout after clicking "Add a privilege" button', () => { + it('Renders flyout after clicking "Add space privilege" button', () => { const props = buildProps({ role: { elasticsearch: { @@ -111,7 +100,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); expect(wrapper.find(PrivilegeSpaceForm)).toHaveLength(0); wrapper.find('button[data-test-subj="addSpacePrivilegeButton"]').simulate('click'); @@ -119,7 +108,7 @@ describe('', () => { expect(wrapper.find(PrivilegeSpaceForm)).toHaveLength(1); }); - it('hides privilege matrix when the role is reserved', () => { + it('hides privilege summary when the role is reserved', () => { const props = buildProps({ role: { name: '', @@ -135,8 +124,8 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); - expect(wrapper.find(PrivilegeMatrix)).toHaveLength(0); + const wrapper = mountWithIntl(); + expect(wrapper.find(PrivilegeSummary)).toHaveLength(0); }); describe('with base privilege set to "read"', () => { @@ -156,7 +145,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(1); @@ -183,7 +172,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(1); @@ -202,7 +191,7 @@ describe('', () => { }, }); - const wrapper = shallowWithIntl(); + const wrapper = shallowWithIntl(); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx index a847ccb6774852..86b09e53327926 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx @@ -10,47 +10,49 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, + EuiErrorBoundary, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { Component, Fragment } from 'react'; import { Capabilities } from 'src/core/public'; import { Space } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role, isRoleReserved } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role, isRoleReserved } from '../../../../../../../common/model'; import { RoleValidator } from '../../../validate_role'; -import { PrivilegeMatrix } from './privilege_matrix'; -import { PrivilegeSpaceForm } from './privilege_space_form'; import { PrivilegeSpaceTable } from './privilege_space_table'; +import { PrivilegeSpaceForm } from './privilege_space_form'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { PrivilegeSummary } from '../privilege_summary'; +import { KibanaPrivileges } from '../../../../model'; interface Props { kibanaPrivileges: KibanaPrivileges; role: Role; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; spaces: Space[]; onChange: (role: Role) => void; editable: boolean; + canCustomizeSubFeaturePrivileges: boolean; validator: RoleValidator; - intl: InjectedIntl; uiCapabilities: Capabilities; - features: Feature[]; } interface State { role: Role | null; - editingIndex: number; + privilegeIndex: number; showSpacePrivilegeEditor: boolean; showPrivilegeMatrix: boolean; } -class SpaceAwarePrivilegeSectionUI extends Component { +export class SpaceAwarePrivilegeSection extends Component { private globalSpaceEntry: Space = { id: '*', - name: this.props.intl.formatMessage({ - id: 'xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName', - defaultMessage: '* Global (all spaces)', - }), + name: i18n.translate( + 'xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName', + { + defaultMessage: '* Global (all spaces)', + } + ), color: '#D3DAE6', initials: '*', disabledFeatures: [], @@ -63,12 +65,12 @@ class SpaceAwarePrivilegeSectionUI extends Component { showSpacePrivilegeEditor: false, showPrivilegeMatrix: false, role: null, - editingIndex: -1, + privilegeIndex: -1, }; } public render() { - const { uiCapabilities, privilegeCalculatorFactory } = this.props; + const { uiCapabilities } = this.props; if (!uiCapabilities.spaces.manage) { return ( @@ -113,22 +115,22 @@ class SpaceAwarePrivilegeSectionUI extends Component { } return ( - - {this.renderKibanaPrivileges()} - {this.state.showSpacePrivilegeEditor && ( - - )} - + + + {this.renderKibanaPrivileges()} + {this.state.showSpacePrivilegeEditor && ( + + )} + + ); } @@ -143,10 +145,11 @@ class SpaceAwarePrivilegeSectionUI extends Component { ); @@ -205,14 +208,11 @@ class SpaceAwarePrivilegeSectionUI extends Component { } const viewMatrixButton = ( - ); @@ -250,18 +250,18 @@ class SpaceAwarePrivilegeSectionUI extends Component { private addSpacePrivilege = () => { this.setState({ showSpacePrivilegeEditor: true, - editingIndex: -1, + privilegeIndex: -1, }); }; private onSpacesPrivilegeChange = (role: Role) => { - this.setState({ showSpacePrivilegeEditor: false, editingIndex: -1 }); + this.setState({ showSpacePrivilegeEditor: false, privilegeIndex: -1 }); this.props.onChange(role); }; - private onEditSpacesPrivileges = (spacesIndex: number) => { + private onEditSpacesPrivileges = (privilegeIndex: number) => { this.setState({ - editingIndex: spacesIndex, + privilegeIndex, showSpacePrivilegeEditor: true, }); }; @@ -270,5 +270,3 @@ class SpaceAwarePrivilegeSectionUI extends Component { this.setState({ showSpacePrivilegeEditor: false }); }; } - -export const SpaceAwarePrivilegeSection = injectI18n(SpaceAwarePrivilegeSectionUI); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx index 1e42a926c51f76..70790f785ad583 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx @@ -5,9 +5,10 @@ */ import { EuiComboBox, EuiComboBoxOptionOption, EuiHealth, EuiHighlight } from '@elastic/eui'; -import { InjectedIntl } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; -import { Space, getSpaceColor } from '../../../../../../../../spaces/public'; +import { getSpaceColor } from '../../../../../../../../spaces/public'; +import { Space } from '../../../../../../../../spaces/common/model/space'; const spaceToOption = (space?: Space, currentSelection?: 'global' | 'spaces') => { if (!space) { @@ -32,7 +33,6 @@ interface Props { selectedSpaceIds: string[]; onChange: (spaceIds: string[]) => void; disabled?: boolean; - intl: InjectedIntl; } export class SpaceSelector extends Component { @@ -51,8 +51,7 @@ export class SpaceSelector extends Component { return ( { + it('renders a button with the provided text', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find(EuiButtonEmpty).text()).toEqual('hello world'); + expect(wrapper.find(EuiContextMenuPanel)).toHaveLength(0); + }); + + it('clicking the button renders a context menu with the provided spaces', () => { + const wrapper = mountWithIntl(); + wrapper.find(EuiButtonEmpty).simulate('click'); + wrapper.update(); + + const menu = wrapper.find(EuiContextMenuPanel); + expect(menu).toHaveLength(1); + + const items = menu.find(EuiContextMenuItem); + expect(items).toHaveLength(spaces.length); + + spaces.forEach((space, index) => { + const spaceAvatar = items.at(index).find(SpaceAvatar); + expect(spaceAvatar.props().space).toEqual(space); + }); + + expect(wrapper.find(EuiFieldSearch)).toHaveLength(0); + }); + + it('renders a search box when there are 8 or more spaces', () => { + const lotsOfSpaces = [1, 2, 3, 4, 5, 6, 7, 8].map(num => ({ + id: `space-${num}`, + name: `Space ${num}`, + disabledFeatures: [], + })); + + const wrapper = mountWithIntl( + + ); + wrapper.find(EuiButtonEmpty).simulate('click'); + wrapper.update(); + + const menu = wrapper.find(EuiContextMenuPanel).first(); + const items = menu.find(EuiContextMenuItem); + expect(items).toHaveLength(lotsOfSpaces.length); + + const searchField = wrapper.find(EuiFieldSearch); + expect(searchField).toHaveLength(1); + + searchField.props().onSearch!('Space 6'); + wrapper.update(); + expect(wrapper.find(SpaceAvatar)).toHaveLength(1); + + searchField.props().onSearch!('this does not match'); + wrapper.update(); + expect(wrapper.find(SpaceAvatar)).toHaveLength(0); + + const updatedMenu = wrapper.find(EuiContextMenuPanel).first(); + expect(updatedMenu.text()).toMatchInlineSnapshot(`"Spaces no spaces found "`); + }); + + it('can close its popover', () => { + const wrapper = mountWithIntl(); + wrapper.find(EuiButtonEmpty).simulate('click'); + wrapper.update(); + + expect(wrapper.find(EuiPopover).props().isOpen).toEqual(true); + + wrapper + .find(EuiPopover) + .props() + .closePopover(); + + wrapper.update(); + + expect(wrapper.find(EuiPopover).props().isOpen).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx index f8b2991a844f75..92e42ec811afce 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx @@ -12,14 +12,14 @@ import { EuiPopover, EuiText, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; import { Space, SpaceAvatar } from '../../../../../../spaces/public'; import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../../../../spaces/common'; interface Props { spaces: Space[]; - intl: InjectedIntl; buttonText: string; } @@ -59,15 +59,13 @@ export class SpacesPopoverList extends Component { } private getMenuPanel = () => { - const { intl } = this.props; const { searchTerm } = this.state; const items = this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem); const panelProps = { className: 'spcMenu', - title: intl.formatMessage({ - id: 'xpack.security.management.editRole.spacesPopoverList.popoverTitle', + title: i18n.translate('xpack.security.management.editRole.spacesPopoverList.popoverTitle', { defaultMessage: 'Spaces', }), watchedItemProps: ['data-search-term'], @@ -141,15 +139,16 @@ export class SpacesPopoverList extends Component { }; private renderSearchField = () => { - const { intl } = this.props; return (
{ !knownActions.includes(action)); + + const hasAllRequested = + knownActions.length > 0 && candidateActions.length > 0 && missing.length === 0; + + return { + missing, + hasAllRequested, + }; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts new file mode 100644 index 00000000000000..a1f1e36e8df861 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts @@ -0,0 +1,144 @@ +/* + * 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 { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../__fixtures__/kibana_features'; +import { KibanaPrivileges } from './kibana_privileges'; +import { RoleKibanaPrivilege } from '../../../../common/model'; +import { KibanaPrivilege } from './kibana_privilege'; + +describe('KibanaPrivileges', () => { + describe('#getBasePrivileges', () => { + it('returns the space base privileges for a non-global entry', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const entry: RoleKibanaPrivilege = { + base: [], + feature: {}, + spaces: ['foo'], + }; + + const basePrivileges = kibanaPrivileges.getBasePrivileges(entry); + + const expectedPrivileges = rawPrivileges.space; + + expect(basePrivileges).toHaveLength(2); + expect(basePrivileges[0]).toMatchObject({ + id: 'all', + actions: expectedPrivileges.all, + }); + expect(basePrivileges[1]).toMatchObject({ + id: 'read', + actions: expectedPrivileges.read, + }); + }); + + it('returns the global base privileges for a global entry', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const entry: RoleKibanaPrivilege = { + base: [], + feature: {}, + spaces: ['*'], + }; + + const basePrivileges = kibanaPrivileges.getBasePrivileges(entry); + + const expectedPrivileges = rawPrivileges.global; + + expect(basePrivileges).toHaveLength(2); + expect(basePrivileges[0]).toMatchObject({ + id: 'all', + actions: expectedPrivileges.all, + }); + expect(basePrivileges[1]).toMatchObject({ + id: 'read', + actions: expectedPrivileges.read, + }); + }); + }); + + describe('#createCollectionFromRoleKibanaPrivileges', () => { + it('creates a collection from a role with no privileges assigned', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = []; + kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); + }); + + it('creates a collection ignoring unknown privileges', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = [ + { + base: ['read', 'some-unknown-base-privilege'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all', 'some-unknown-feature-privilege'], + some_unknown_feature: ['all'], + }, + spaces: ['foo'], + }, + ]; + kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); + }); + + it('creates a collection using all assigned privileges, and only the assigned privileges', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = [ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all'], + }, + spaces: ['foo'], + }, + ]; + const collection = kibanaPrivileges.createCollectionFromRoleKibanaPrivileges( + assignedPrivileges + ); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_excluded_sub_features.read]) + ) + ).toEqual(true); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_excluded_sub_features.all]) + ) + ).toEqual(false); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_sub_features.cool_all]) + ) + ).toEqual(true); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_sub_features.cool_toggle_1]) + ) + ).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts new file mode 100644 index 00000000000000..d8d75e90847e36 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts @@ -0,0 +1,86 @@ +/* + * 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 { RawKibanaPrivileges, RoleKibanaPrivilege } from '../../../../common/model'; +import { KibanaPrivilege } from './kibana_privilege'; +import { PrivilegeCollection } from './privilege_collection'; +import { SecuredFeature } from './secured_feature'; +import { Feature } from '../../../../../features/common'; +import { isGlobalPrivilegeDefinition } from '../edit_role/privilege_utils'; + +function toBasePrivilege(entry: [string, string[]]): [string, KibanaPrivilege] { + const [privilegeId, actions] = entry; + return [privilegeId, new KibanaPrivilege(privilegeId, actions)]; +} + +function recordsToBasePrivilegeMap( + record: Record +): ReadonlyMap { + return new Map(Object.entries(record).map(entry => toBasePrivilege(entry))); +} + +export class KibanaPrivileges { + private global: ReadonlyMap; + + private spaces: ReadonlyMap; + + private feature: ReadonlyMap; + + constructor(rawKibanaPrivileges: RawKibanaPrivileges, features: Feature[]) { + this.global = recordsToBasePrivilegeMap(rawKibanaPrivileges.global); + this.spaces = recordsToBasePrivilegeMap(rawKibanaPrivileges.space); + this.feature = new Map( + features.map(feature => { + const rawPrivs = rawKibanaPrivileges.features[feature.id]; + return [feature.id, new SecuredFeature(feature.toRaw(), rawPrivs)]; + }) + ); + } + + public getBasePrivileges(entry: RoleKibanaPrivilege) { + if (isGlobalPrivilegeDefinition(entry)) { + return Array.from(this.global.values()); + } + return Array.from(this.spaces.values()); + } + + public getSecuredFeature(featureId: string) { + return this.feature.get(featureId)!; + } + + public getSecuredFeatures() { + return Array.from(this.feature.values()); + } + + public createCollectionFromRoleKibanaPrivileges(roleKibanaPrivileges: RoleKibanaPrivilege[]) { + const filterAssigned = (assignedPrivileges: string[]) => (privilege: KibanaPrivilege) => + assignedPrivileges.includes(privilege.id); + + const privileges: KibanaPrivilege[] = roleKibanaPrivileges + .map(entry => { + const assignedBasePrivileges = this.getBasePrivileges(entry).filter( + filterAssigned(entry.base) + ); + + const assignedFeaturePrivileges: KibanaPrivilege[][] = Object.entries(entry.feature).map( + ([featureId, assignedFeaturePrivs]) => { + return this.getFeaturePrivileges(featureId).filter( + filterAssigned(assignedFeaturePrivs) + ); + } + ); + + return [assignedBasePrivileges, assignedFeaturePrivileges].flat(2); + }) + .flat(); + + return new PrivilegeCollection(privileges); + } + + private getFeaturePrivileges(featureId: string) { + return this.getSecuredFeature(featureId)?.getAllPrivileges() ?? []; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts b/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts new file mode 100644 index 00000000000000..9ed460fe734ef8 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts @@ -0,0 +1,29 @@ +/* + * 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 { KibanaPrivilege } from './kibana_privilege'; +import { FeatureKibanaPrivileges } from '../../../../../features/public'; + +export class PrimaryFeaturePrivilege extends KibanaPrivilege { + constructor( + id: string, + protected readonly config: FeatureKibanaPrivileges, + public readonly actions: string[] = [] + ) { + super(id, actions); + } + + public isMinimalFeaturePrivilege() { + return this.id.startsWith('minimal_'); + } + + public getMinimalPrivilegeId() { + if (this.isMinimalFeaturePrivilege()) { + return this.id; + } + return `minimal_${this.id}`; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts b/x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts new file mode 100644 index 00000000000000..6b1c3785721b3f --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { KibanaPrivilege } from './kibana_privilege'; +import { PrivilegeCollection } from './privilege_collection'; + +describe('PrivilegeCollection', () => { + describe('#grantsPrivilege', () => { + it('returns true when the collection contains the same privilege being tested', () => { + const privilege = new KibanaPrivilege('some-privilege', ['action:foo', 'action:bar']); + const collection = new PrivilegeCollection([privilege]); + + expect(collection.grantsPrivilege(privilege)).toEqual(true); + }); + + it('returns false when a non-empty collection tests an empty privilege', () => { + const privilege = new KibanaPrivilege('some-privilege', ['action:foo', 'action:bar']); + const collection = new PrivilegeCollection([privilege]); + + expect(collection.grantsPrivilege(new KibanaPrivilege('test', []))).toEqual(false); + }); + + it('returns true for collections comprised of multiple privileges, with actions spanning them', () => { + const collection = new PrivilegeCollection([ + new KibanaPrivilege('privilege1', ['action:foo', 'action:bar']), + new KibanaPrivilege('privilege1', ['action:baz']), + ]); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', ['action:foo', 'action:bar', 'action:baz']) + ) + ).toEqual(true); + }); + + it('returns false for collections which do not contain all necessary actions', () => { + const collection = new PrivilegeCollection([ + new KibanaPrivilege('privilege1', ['action:foo', 'action:bar']), + new KibanaPrivilege('privilege1', ['action:baz']), + ]); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', ['action:foo', 'action:bar', 'action:baz', 'actions:secret']) + ) + ).toEqual(false); + }); + + it('returns false for collections which contain no privileges', () => { + const collection = new PrivilegeCollection([]); + + expect(collection.grantsPrivilege(new KibanaPrivilege('test', ['action:foo']))).toEqual( + false + ); + }); + + it('returns false for collections which contain no privileges, even if the requested privilege has no actions', () => { + const collection = new PrivilegeCollection([]); + + expect(collection.grantsPrivilege(new KibanaPrivilege('test', []))).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/model/privilege_collection.ts b/x-pack/plugins/security/public/management/roles/model/privilege_collection.ts new file mode 100644 index 00000000000000..cbbd22857666e7 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/privilege_collection.ts @@ -0,0 +1,33 @@ +/* + * 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 { KibanaPrivilege } from './kibana_privilege'; + +export class PrivilegeCollection { + private actions: ReadonlySet; + + constructor(privileges: KibanaPrivilege[]) { + this.actions = new Set( + privileges.reduce((acc, priv) => [...acc, ...priv.actions], [] as string[]) + ); + } + + public grantsPrivilege(privilege: KibanaPrivilege) { + return this.checkActions(this.actions, privilege.actions).hasAllRequested; + } + + private checkActions(knownActions: ReadonlySet, candidateActions: string[]) { + const missing = candidateActions.filter(action => !knownActions.has(action)); + + const hasAllRequested = + knownActions.size > 0 && candidateActions.length > 0 && missing.length === 0; + + return { + missing, + hasAllRequested, + }; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/secured_feature.ts b/x-pack/plugins/security/public/management/roles/model/secured_feature.ts new file mode 100644 index 00000000000000..7fc466a70b9849 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/secured_feature.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 { Feature, FeatureConfig } from '../../../../../features/common'; +import { PrimaryFeaturePrivilege } from './primary_feature_privilege'; +import { SecuredSubFeature } from './secured_sub_feature'; +import { SubFeaturePrivilege } from './sub_feature_privilege'; + +export class SecuredFeature extends Feature { + private readonly primaryFeaturePrivileges: PrimaryFeaturePrivilege[]; + + private readonly minimalPrimaryFeaturePrivileges: PrimaryFeaturePrivilege[]; + + private readonly subFeaturePrivileges: SubFeaturePrivilege[]; + + private readonly securedSubFeatures: SecuredSubFeature[]; + + constructor(config: FeatureConfig, actionMapping: { [privilegeId: string]: string[] } = {}) { + super(config); + this.primaryFeaturePrivileges = Object.entries(this.config.privileges || {}).map( + ([id, privilege]) => new PrimaryFeaturePrivilege(id, privilege, actionMapping[id]) + ); + + if (this.config.subFeatures?.length ?? 0 > 0) { + this.minimalPrimaryFeaturePrivileges = Object.entries(this.config.privileges || {}).map( + ([id, privilege]) => + new PrimaryFeaturePrivilege(`minimal_${id}`, privilege, actionMapping[`minimal_${id}`]) + ); + } else { + this.minimalPrimaryFeaturePrivileges = []; + } + + this.securedSubFeatures = + this.config.subFeatures?.map(sf => new SecuredSubFeature(sf, actionMapping)) ?? []; + + this.subFeaturePrivileges = this.securedSubFeatures.reduce((acc, subFeature) => { + return [...acc, ...subFeature.privilegeIterator()]; + }, [] as SubFeaturePrivilege[]); + } + + public getPrivilegesTooltip() { + return this.config.privilegesTooltip; + } + + public getAllPrivileges() { + return [ + ...this.primaryFeaturePrivileges, + ...this.minimalPrimaryFeaturePrivileges, + ...this.subFeaturePrivileges, + ]; + } + + public getPrimaryFeaturePrivileges( + { includeMinimalFeaturePrivileges }: { includeMinimalFeaturePrivileges: boolean } = { + includeMinimalFeaturePrivileges: false, + } + ) { + return includeMinimalFeaturePrivileges + ? [this.primaryFeaturePrivileges, this.minimalPrimaryFeaturePrivileges].flat() + : [...this.primaryFeaturePrivileges]; + } + + public getMinimalFeaturePrivileges() { + return [...this.minimalPrimaryFeaturePrivileges]; + } + + public getSubFeaturePrivileges() { + return [...this.subFeaturePrivileges]; + } + + public getSubFeatures() { + return [...this.securedSubFeatures]; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts b/x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts new file mode 100644 index 00000000000000..3d69e5e709bb0a --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/secured_sub_feature.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 { SubFeature, SubFeatureConfig } from '../../../../../features/common'; +import { SubFeaturePrivilege } from './sub_feature_privilege'; +import { SubFeaturePrivilegeGroup } from './sub_feature_privilege_group'; + +export class SecuredSubFeature extends SubFeature { + public readonly privileges: SubFeaturePrivilege[]; + + constructor( + config: SubFeatureConfig, + private readonly actionMapping: { [privilegeId: string]: string[] } = {} + ) { + super(config); + + this.privileges = []; + for (const privilege of this.privilegeIterator()) { + this.privileges.push(privilege); + } + } + + public getPrivilegeGroups() { + return this.privilegeGroups.map(pg => new SubFeaturePrivilegeGroup(pg, this.actionMapping)); + } + + public *privilegeIterator({ + predicate = () => true, + }: { + predicate?: (privilege: SubFeaturePrivilege, feature: SecuredSubFeature) => boolean; + } = {}): IterableIterator { + for (const group of this.privilegeGroups) { + yield* group.privileges + .map(gp => new SubFeaturePrivilege(gp, this.actionMapping[gp.id])) + .filter(privilege => predicate(privilege, this)); + } + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts new file mode 100644 index 00000000000000..e149a59e12edfc --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts @@ -0,0 +1,21 @@ +/* + * 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 { SubFeaturePrivilegeConfig } from '../../../../../features/public'; +import { KibanaPrivilege } from './kibana_privilege'; + +export class SubFeaturePrivilege extends KibanaPrivilege { + constructor( + protected readonly subPrivilegeConfig: SubFeaturePrivilegeConfig, + public readonly actions: string[] = [] + ) { + super(subPrivilegeConfig.id, actions); + } + + public get name() { + return this.subPrivilegeConfig.name; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts new file mode 100644 index 00000000000000..b437649236e278 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts @@ -0,0 +1,25 @@ +/* + * 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 { SubFeaturePrivilegeGroupConfig } from '../../../../../features/common'; +import { SubFeaturePrivilege } from './sub_feature_privilege'; + +export class SubFeaturePrivilegeGroup { + constructor( + private readonly config: SubFeaturePrivilegeGroupConfig, + private readonly actionMapping: { [privilegeId: string]: string[] } = {} + ) {} + + public get groupType() { + return this.config.groupType; + } + + public get privileges() { + return this.config.privileges.map( + p => new SubFeaturePrivilege(p, this.actionMapping[p.id] || []) + ); + } +} diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index 5936409eb6e8b7..96051dbd7fa568 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -17,17 +17,22 @@ jest.mock('./edit_role', () => ({ import { rolesManagementApp } from './roles_management_app'; import { coreMock } from '../../../../../../src/core/public/mocks'; +import { featuresPluginMock } from '../../../../features/public/mocks'; async function mountApp(basePath: string) { const { fatalErrors } = coreMock.createSetup(); const container = document.createElement('div'); const setBreadcrumbs = jest.fn(); + const featuresStart = featuresPluginMock.createStart(); + const unmount = await rolesManagementApp .create({ license: licenseMock.create(), fatalErrors, - getStartServices: jest.fn().mockResolvedValue([coreMock.createStart(), { data: {} }]), + getStartServices: jest + .fn() + .mockResolvedValue([coreMock.createStart(), { data: {}, features: featuresStart }]), }) .mount({ basePath, element: container, setBreadcrumbs }); diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx index 4265cac22ece05..e1a10fdc2b8c33 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx @@ -36,7 +36,7 @@ export const rolesManagementApp = Object.freeze({ async mount({ basePath, element, setBreadcrumbs }) { const [ { application, docLinks, http, i18n: i18nStart, injectedMetadata, notifications }, - { data }, + { data, features }, ] = await getStartServices(); const rolesBreadcrumbs = [ @@ -77,6 +77,7 @@ export const rolesManagementApp = Object.freeze({ userAPIClient={new UserAPIClient(http)} indicesAPIClient={new IndicesAPIClient(http)} privilegesAPIClient={new PrivilegesAPIClient(http)} + getFeatures={features.getFeatures} http={http} notifications={notifications} fatalErrors={fatalErrors} diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 3d0ef3b2cabc7c..122b26378d22b0 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -15,6 +15,7 @@ import { coreMock } from '../../../../src/core/public/mocks'; import { managementPluginMock } from '../../../../src/plugins/management/public/mocks'; import { licensingMock } from '../../licensing/public/mocks'; import { ManagementService } from './management'; +import { FeaturesPluginStart } from '../../features/public'; describe('Security Plugin', () => { beforeAll(() => { @@ -86,6 +87,7 @@ describe('Security Plugin', () => { expect( plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { data: {} as DataPublicPluginStart, + features: {} as FeaturesPluginStart, }) ).toBeUndefined(); }); @@ -110,6 +112,7 @@ describe('Security Plugin', () => { plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { data: {} as DataPublicPluginStart, + features: {} as FeaturesPluginStart, management: managementStartMock, }); @@ -139,6 +142,7 @@ describe('Security Plugin', () => { plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { data: {} as DataPublicPluginStart, + features: {} as FeaturesPluginStart, }); expect(() => plugin.stop()).not.toThrow(); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index dcd90b1738f10c..38ef552e75a9e5 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -11,6 +11,7 @@ import { Plugin, PluginInitializerContext, } from '../../../../src/core/public'; +import { FeaturesPluginStart } from '../../features/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { FeatureCatalogueCategory, @@ -40,6 +41,7 @@ export interface PluginSetupDependencies { export interface PluginStartDependencies { data: DataPublicPluginStart; + features: FeaturesPluginStart; management?: ManagementStart; } diff --git a/x-pack/plugins/security/server/authorization/actions/actions.ts b/x-pack/plugins/security/server/authorization/actions/actions.ts index 4bf7a41550cc65..00293e88abe764 100644 --- a/x-pack/plugins/security/server/authorization/actions/actions.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.ts @@ -15,13 +15,6 @@ import { UIActions } from './ui'; * by the various `checkPrivilegesWithRequest` derivatives */ export class Actions { - /** - * The allHack action is used to differentiate the `all` privilege from the `read` privilege - * for those applications which register the same set of actions for both privileges. This is a - * temporary hack until we remove this assumption in the role management UI - */ - public readonly allHack = 'allHack:'; - public readonly api = new ApiActions(this.versionNumber); public readonly app = new AppActions(this.versionNumber); diff --git a/x-pack/plugins/security/server/authorization/actions/api.test.ts b/x-pack/plugins/security/server/authorization/actions/api.test.ts index 60a42ba6a78a23..d6e7a5d242d494 100644 --- a/x-pack/plugins/security/server/authorization/actions/api.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/api.test.ts @@ -8,13 +8,6 @@ import { ApiActions } from './api'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test('returns `api:${version}:*`', () => { - const apiActions = new ApiActions(version); - expect(apiActions.all).toBe('api:1.0.0-zeta1:*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((operation: any) => { test(`operation of ${JSON.stringify(operation)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/api.ts b/x-pack/plugins/security/server/authorization/actions/api.ts index 35e614e7a03d4c..60b135acc15ef4 100644 --- a/x-pack/plugins/security/server/authorization/actions/api.ts +++ b/x-pack/plugins/security/server/authorization/actions/api.ts @@ -12,10 +12,6 @@ export class ApiActions { this.prefix = `api:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - public get(operation: string) { if (!operation || !isString(operation)) { throw new Error('operation is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/actions/app.test.ts b/x-pack/plugins/security/server/authorization/actions/app.test.ts index a696fd8693997d..74c372a0699a25 100644 --- a/x-pack/plugins/security/server/authorization/actions/app.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/app.test.ts @@ -8,13 +8,6 @@ import { AppActions } from './app'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test('returns `app:${version}:*`', () => { - const appActions = new AppActions(version); - expect(appActions.all).toBe('app:1.0.0-zeta1:*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((appid: any) => { test(`appId of ${JSON.stringify(appid)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/app.ts b/x-pack/plugins/security/server/authorization/actions/app.ts index ed0854e8a805b4..227c6586191757 100644 --- a/x-pack/plugins/security/server/authorization/actions/app.ts +++ b/x-pack/plugins/security/server/authorization/actions/app.ts @@ -12,10 +12,6 @@ export class AppActions { this.prefix = `app:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - public get(appId: string) { if (!appId || !isString(appId)) { throw new Error('appId is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts index 5e5da7233d93e6..9e8bfb6ad795f1 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts @@ -8,13 +8,6 @@ import { SavedObjectActions } from './saved_object'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test(`returns saved_object:*`, () => { - const savedObjectActions = new SavedObjectActions(version); - expect(savedObjectActions.all).toBe('saved_object:1.0.0-zeta1:*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((type: any) => { test(`type of ${JSON.stringify(type)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.ts index 4a0bc7cda1b8f7..e3a02d38073998 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.ts @@ -13,10 +13,6 @@ export class SavedObjectActions { this.prefix = `saved_object:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - public get(type: string, operation: string): string { if (!type || !isString(type)) { throw new Error('type is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/actions/ui.test.ts b/x-pack/plugins/security/server/authorization/actions/ui.test.ts index f91b7baf78baad..32827822117d0a 100644 --- a/x-pack/plugins/security/server/authorization/actions/ui.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/ui.test.ts @@ -8,34 +8,6 @@ import { UIActions } from './ui'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test('returns `ui:${version}:*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.all).toBe('ui:1.0.0-zeta1:*'); - }); -}); - -describe('#allNavlinks', () => { - test('returns `ui:${version}:navLinks/*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.allNavLinks).toBe('ui:1.0.0-zeta1:navLinks/*'); - }); -}); - -describe('#allCatalogueEntries', () => { - test('returns `ui:${version}:catalogue/*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.allCatalogueEntries).toBe('ui:1.0.0-zeta1:catalogue/*'); - }); -}); - -describe('#allManagementLinks', () => { - test('returns `ui:${version}:management/*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.allManagementLinks).toBe('ui:1.0.0-zeta1:management/*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((featureId: any) => { test(`featureId of ${JSON.stringify(featureId)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/ui.ts b/x-pack/plugins/security/server/authorization/actions/ui.ts index 9e77c319a9b3ab..3dae9a47b38273 100644 --- a/x-pack/plugins/security/server/authorization/actions/ui.ts +++ b/x-pack/plugins/security/server/authorization/actions/ui.ts @@ -14,22 +14,6 @@ export class UIActions { this.prefix = `ui:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - - public get allNavLinks(): string { - return `${this.prefix}navLinks/*`; - } - - public get allCatalogueEntries(): string { - return `${this.prefix}catalogue/*`; - } - - public get allManagementLinks(): string { - return `${this.prefix}management/*`; - } - public get(featureId: keyof UICapabilities, ...uiCapabilityParts: string[]) { if (!featureId || !isString(featureId)) { throw new Error('featureId is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index 49c9db2d0e6e36..912ae60e12065d 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -9,6 +9,7 @@ import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; import { httpServerMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; import { authorizationMock } from './index.mock'; +import { Feature } from '../../../features/server'; type MockAuthzOptions = { rejectCheckPrivileges: any } | { resolveCheckPrivileges: any }; @@ -42,7 +43,15 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, - [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + [ + new Feature({ + id: 'fooFeature', + name: 'Foo Feature', + app: [], + navLinkId: 'foo', + privileges: null, + }), + ], mockLoggers.get(), mockAuthz ); @@ -108,7 +117,15 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, - [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + [ + new Feature({ + id: 'fooFeature', + name: 'Foo Feature', + app: [], + navLinkId: 'foo', + privileges: null, + }), + ], mockLoggers.get(), mockAuthz ); @@ -226,20 +243,20 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [ - { + new Feature({ id: 'fooFeature', name: 'Foo Feature', navLinkId: 'foo', app: [], - privileges: {}, - }, - { + privileges: null, + }), + new Feature({ id: 'barFeature', name: 'Bar Feature', navLinkId: 'bar', app: [], - privileges: {}, - }, + privileges: null, + }), ], loggingServiceMock.create().get(), mockAuthz @@ -312,20 +329,20 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [ - { + new Feature({ id: 'fooFeature', name: 'Foo Feature', navLinkId: 'foo', app: [], - privileges: {}, - }, - { + privileges: null, + }), + new Feature({ id: 'barFeature', name: 'Bar Feature', navLinkId: 'bar', app: [], - privileges: {}, - }, + privileges: null, + }), ], loggingServiceMock.create().get(), mockAuthz @@ -383,7 +400,15 @@ describe('all', () => { const { all } = disableUICapabilitiesFactory( mockRequest, - [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + [ + new Feature({ + id: 'fooFeature', + name: 'Foo Feature', + app: [], + navLinkId: 'foo', + privileges: null, + }), + ], loggingServiceMock.create().get(), mockAuthz ); diff --git a/x-pack/plugins/security/server/authorization/index.test.ts b/x-pack/plugins/security/server/authorization/index.test.ts index 9e99cae6206334..32520534547649 100644 --- a/x-pack/plugins/security/server/authorization/index.test.ts +++ b/x-pack/plugins/security/server/authorization/index.test.ts @@ -93,7 +93,7 @@ test(`returns exposed services`, () => { ); expect(authz.privileges).toBe(mockPrivilegesService); - expect(privilegesFactory).toHaveBeenCalledWith(authz.actions, mockFeaturesService); + expect(privilegesFactory).toHaveBeenCalledWith(authz.actions, mockFeaturesService, mockLicense); expect(authz.mode).toBe(mockAuthorizationMode); expect(authorizationModeFactory).toHaveBeenCalledWith(mockLicense); diff --git a/x-pack/plugins/security/server/authorization/index.ts b/x-pack/plugins/security/server/authorization/index.ts index 4cbc76ecb6be43..f065c9cfd90bac 100644 --- a/x-pack/plugins/security/server/authorization/index.ts +++ b/x-pack/plugins/security/server/authorization/index.ts @@ -35,6 +35,7 @@ import { SecurityLicense } from '../../common/licensing'; export { Actions } from './actions'; export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges'; +export { featurePrivilegeIterator } from './privileges'; interface SetupAuthorizationParams { packageVersion: string; @@ -80,7 +81,7 @@ export function setupAuthorization({ clusterClient, applicationName ); - const privileges = privilegesFactory(actions, featuresService); + const privileges = privilegesFactory(actions, featuresService, license); const logger = loggers.get('authorization'); const authz = { @@ -120,7 +121,7 @@ export function setupAuthorization({ }, registerPrivilegesWithCluster: async () => { - validateFeaturePrivileges(actions, featuresService.getFeatures()); + validateFeaturePrivileges(featuresService.getFeatures()); await registerPrivilegesWithCluster(logger, privileges, applicationName, clusterClient); }, diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts index c874886d908eb2..514d6734b47ba5 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts @@ -9,7 +9,7 @@ import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeAppBuilder extends BaseFeaturePrivilegeBuilder { public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { - const appIds = privilegeDefinition.app || feature.app; + const appIds = privilegeDefinition.app; if (!appIds) { return []; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts index 3dbe71db93f4a3..fc15aff32b975a 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts @@ -9,7 +9,7 @@ import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeCatalogueBuilder extends BaseFeaturePrivilegeBuilder { public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { - const catalogueEntries = privilegeDefinition.catalogue || feature.catalogue; + const catalogueEntries = privilegeDefinition.catalogue; if (!catalogueEntries) { return []; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts index 0180554a47ccc9..7a2bb87d72b456 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts @@ -9,7 +9,7 @@ import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeManagementBuilder extends BaseFeaturePrivilegeBuilder { public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { - const managementSections = privilegeDefinition.management || feature.management; + const managementSections = privilegeDefinition.management; if (!managementSections) { return []; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts new file mode 100644 index 00000000000000..7d92eacfe6b35e --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts @@ -0,0 +1,891 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Feature } from '../../../../../features/server'; +import { featurePrivilegeIterator } from './feature_privilege_iterator'; + +describe('featurePrivilegeIterator', () => { + it('handles features with no privileges', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + privileges: null, + app: [], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toHaveLength(0); + }); + + it('handles features with no sub-features', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + app: ['foo'], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('filters privileges using the provided predicate', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + app: ['foo'], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + predicate: privilegeId => privilegeId === 'all', + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('ignores sub features when `augmentWithSubFeaturePrivileges` is false', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: false, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('ignores sub features when `includeIn` is none, even if `augmentWithSubFeaturePrivileges` is true', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'none', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('includes sub feature privileges into both all and read when`augmentWithSubFeaturePrivileges` is true and `includeIn: read`', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api', 'sub-feature-api'], + app: ['foo', 'sub-app'], + catalogue: ['foo-catalogue', 'sub-catalogue'], + management: { + section: ['foo-management', 'other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-type', 'all-sub-type'], + read: ['read-type', 'read-sub-type'], + }, + ui: ['ui-action', 'ui-sub-type'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api', 'sub-feature-api'], + app: ['foo', 'sub-app'], + catalogue: ['foo-catalogue', 'sub-catalogue'], + management: { + section: ['foo-management', 'other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-type', 'read-sub-type'], + }, + ui: ['ui-action', 'ui-sub-type'], + }, + }, + ]); + }); + + it('does not duplicate privileges when merging', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('includes sub feature privileges into both all and read when`augmentWithSubFeaturePrivileges` is true and `includeIn: all`', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'all', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api', 'sub-feature-api'], + app: ['foo', 'sub-app'], + catalogue: ['foo-catalogue', 'sub-catalogue'], + management: { + section: ['foo-management', 'other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-type', 'all-sub-type'], + read: ['read-type', 'read-sub-type'], + }, + ui: ['ui-action', 'ui-sub-type'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it(`can augment primary feature privileges even if they don't specify their own`, () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + }, + ]); + }); + + it(`can augment primary feature privileges even if the sub-feature privileges don't specify their own`, () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts new file mode 100644 index 00000000000000..e239a6e280aec6 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts @@ -0,0 +1,83 @@ +/* + * 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 { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { subFeaturePrivilegeIterator } from './sub_feature_privilege_iterator'; + +interface IteratorOptions { + augmentWithSubFeaturePrivileges: boolean; + predicate?: (privilegeId: string, privilege: FeatureKibanaPrivileges) => boolean; +} + +export function* featurePrivilegeIterator( + feature: Feature, + options: IteratorOptions +): IterableIterator<{ privilegeId: string; privilege: FeatureKibanaPrivileges }> { + for (const entry of Object.entries(feature.privileges ?? {})) { + const [privilegeId, privilege] = entry; + + if (options.predicate && !options.predicate(privilegeId, privilege)) { + continue; + } + + if (options.augmentWithSubFeaturePrivileges) { + yield { privilegeId, privilege: mergeWithSubFeatures(privilegeId, privilege, feature) }; + } else { + yield { privilegeId, privilege }; + } + } +} + +function mergeWithSubFeatures( + privilegeId: string, + privilege: FeatureKibanaPrivileges, + feature: Feature +) { + const mergedConfig = _.cloneDeep(privilege); + for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature)) { + if (subFeaturePrivilege.includeIn !== 'read' && subFeaturePrivilege.includeIn !== privilegeId) { + continue; + } + + mergedConfig.api = mergeArrays(mergedConfig.api, subFeaturePrivilege.api); + + mergedConfig.app = mergeArrays(mergedConfig.app, subFeaturePrivilege.app); + + mergedConfig.catalogue = mergeArrays(mergedConfig.catalogue, subFeaturePrivilege.catalogue); + + const managementEntries = Object.entries(mergedConfig.management ?? {}); + const subFeatureManagementEntries = Object.entries(subFeaturePrivilege.management ?? {}); + + mergedConfig.management = [managementEntries, subFeatureManagementEntries] + .flat() + .reduce((acc, [sectionId, managementApps]) => { + return { + ...acc, + [sectionId]: mergeArrays(acc[sectionId], managementApps), + }; + }, {} as Record); + + mergedConfig.ui = mergeArrays(mergedConfig.ui, subFeaturePrivilege.ui); + + mergedConfig.savedObject.all = mergeArrays( + mergedConfig.savedObject.all, + subFeaturePrivilege.savedObject.all + ); + + mergedConfig.savedObject.read = mergeArrays( + mergedConfig.savedObject.read, + subFeaturePrivilege.savedObject.read + ); + } + return mergedConfig; +} + +function mergeArrays(input1: string[] | undefined, input2: string[] | undefined) { + const first = input1 ?? []; + const second = input2 ?? []; + return Array.from(new Set([...first, ...second])); +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/index.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/index.ts similarity index 57% rename from x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/index.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/index.ts index 253dcaed9f19e0..24af524c350b0c 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/index.ts @@ -4,6 +4,5 @@ * 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'; +export { featurePrivilegeIterator } from './feature_privilege_iterator'; +export { subFeaturePrivilegeIterator } from './sub_feature_privilege_iterator'; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts new file mode 100644 index 00000000000000..b288262be25c67 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts @@ -0,0 +1,18 @@ +/* + * 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 { SubFeaturePrivilegeConfig } from '../../../../../features/common'; +import { Feature } from '../../../../../features/server'; + +export function* subFeaturePrivilegeIterator( + feature: Feature +): IterableIterator { + for (const subFeature of feature.subFeatures) { + for (const group of subFeature.privilegeGroups) { + yield* group.privileges; + } + } +} diff --git a/x-pack/plugins/security/server/authorization/privileges/index.ts b/x-pack/plugins/security/server/authorization/privileges/index.ts index 22b9cd45d4c0fe..e12a33ce509bd5 100644 --- a/x-pack/plugins/security/server/authorization/privileges/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/index.ts @@ -5,3 +5,4 @@ */ export { privilegesFactory, PrivilegesService } from './privileges'; +export { featurePrivilegeIterator } from './feature_privilege_iterator'; diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 38d4d413c591e5..3d25fc03f568b6 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -11,9 +11,9 @@ import { privilegesFactory } from './privileges'; const actions = new Actions('1.0.0-zeta1'); describe('features', () => { - test('actions defined at the feature cascade to the privileges', () => { + test('actions defined at the feature do not cascade to the privileges', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo-feature', name: 'Foo Feature', icon: 'arrowDown', @@ -39,115 +39,25 @@ describe('features', () => { ui: [], }, }, - }, + }), ]; const mockFeaturesService = { getFeatures: jest.fn().mockReturnValue(features) }; - const privileges = privilegesFactory(actions, mockFeaturesService); - - const actual = privileges.get(); - expect(actual).toHaveProperty('features.foo-feature', { - all: [ - actions.login, - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - ], - }); - }); - - test('actions defined at the privilege take precedence', () => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo Feature', - icon: 'arrowDown', - app: ['ignore-me-1', 'ignore-me-2'], - catalogue: ['ignore-me-1', 'ignore-me-2'], - management: { - foo: ['ignore-me-1', 'ignore-me-2'], - }, - privileges: { - all: { - app: ['all-app-1', 'all-app-2'], - catalogue: ['catalogue-all-1', 'catalogue-all-2'], - management: { - all: ['all-management-1', 'all-management-2'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - read: { - app: ['read-app-1', 'read-app-2'], - catalogue: ['catalogue-read-1', 'catalogue-read-2'], - management: { - read: ['read-management-1', 'read-management-2'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - }, - }, - ]; - - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const privileges = privilegesFactory(actions, mockFeaturesService, mockLicenseService); const actual = privileges.get(); - expect(actual).toHaveProperty('features.foo', { - all: [ - actions.login, - actions.version, - actions.app.get('all-app-1'), - actions.app.get('all-app-2'), - actions.ui.get('catalogue', 'catalogue-all-1'), - actions.ui.get('catalogue', 'catalogue-all-2'), - actions.ui.get('management', 'all', 'all-management-1'), - actions.ui.get('management', 'all', 'all-management-2'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.app.get('read-app-1'), - actions.app.get('read-app-2'), - actions.ui.get('catalogue', 'catalogue-read-1'), - actions.ui.get('catalogue', 'catalogue-read-2'), - actions.ui.get('management', 'read', 'read-management-1'), - actions.ui.get('management', 'read', 'read-management-2'), - ], + expect(actual).toHaveProperty('features.foo-feature', { + all: [actions.login, actions.version, actions.ui.get('navLinks', 'kibana:foo')], + read: [actions.login, actions.version, actions.ui.get('navLinks', 'kibana:foo')], }); }); test(`actions only specified at the privilege are alright too`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -168,93 +78,100 @@ describe('features', () => { ui: ['read-ui-1', 'read-ui-2'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const expectedAllPrivileges = [ + actions.login, + actions.version, + actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), + actions.savedObject.get('all-savedObject-all-1', 'get'), + actions.savedObject.get('all-savedObject-all-1', 'find'), + actions.savedObject.get('all-savedObject-all-1', 'create'), + actions.savedObject.get('all-savedObject-all-1', 'bulk_create'), + actions.savedObject.get('all-savedObject-all-1', 'update'), + actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), + actions.savedObject.get('all-savedObject-all-1', 'delete'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), + actions.savedObject.get('all-savedObject-all-2', 'get'), + actions.savedObject.get('all-savedObject-all-2', 'find'), + actions.savedObject.get('all-savedObject-all-2', 'create'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_create'), + actions.savedObject.get('all-savedObject-all-2', 'update'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), + actions.savedObject.get('all-savedObject-all-2', 'delete'), + actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), + actions.savedObject.get('all-savedObject-read-1', 'get'), + actions.savedObject.get('all-savedObject-read-1', 'find'), + actions.savedObject.get('all-savedObject-read-2', 'bulk_get'), + actions.savedObject.get('all-savedObject-read-2', 'get'), + actions.savedObject.get('all-savedObject-read-2', 'find'), + actions.ui.get('foo', 'all-ui-1'), + actions.ui.get('foo', 'all-ui-2'), + ]; + + const expectedReadPrivileges = [ + actions.login, + actions.version, + actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), + actions.savedObject.get('read-savedObject-all-1', 'get'), + actions.savedObject.get('read-savedObject-all-1', 'find'), + actions.savedObject.get('read-savedObject-all-1', 'create'), + actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), + actions.savedObject.get('read-savedObject-all-1', 'update'), + actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), + actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), + actions.savedObject.get('read-savedObject-all-2', 'get'), + actions.savedObject.get('read-savedObject-all-2', 'find'), + actions.savedObject.get('read-savedObject-all-2', 'create'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), + actions.savedObject.get('read-savedObject-all-2', 'update'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), + actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), + actions.savedObject.get('read-savedObject-read-1', 'get'), + actions.savedObject.get('read-savedObject-read-1', 'find'), + actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), + actions.savedObject.get('read-savedObject-read-2', 'get'), + actions.savedObject.get('read-savedObject-read-2', 'find'), + actions.ui.get('foo', 'read-ui-1'), + actions.ui.get('foo', 'read-ui-2'), + ]; const actual = privileges.get(); expect(actual).toHaveProperty('features.foo', { - all: [ - actions.login, - actions.version, - actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), - actions.savedObject.get('all-savedObject-all-1', 'get'), - actions.savedObject.get('all-savedObject-all-1', 'find'), - actions.savedObject.get('all-savedObject-all-1', 'create'), - actions.savedObject.get('all-savedObject-all-1', 'bulk_create'), - actions.savedObject.get('all-savedObject-all-1', 'update'), - actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), - actions.savedObject.get('all-savedObject-all-1', 'delete'), - actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), - actions.savedObject.get('all-savedObject-all-2', 'get'), - actions.savedObject.get('all-savedObject-all-2', 'find'), - actions.savedObject.get('all-savedObject-all-2', 'create'), - actions.savedObject.get('all-savedObject-all-2', 'bulk_create'), - actions.savedObject.get('all-savedObject-all-2', 'update'), - actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), - actions.savedObject.get('all-savedObject-all-2', 'delete'), - actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), - actions.savedObject.get('all-savedObject-read-1', 'get'), - actions.savedObject.get('all-savedObject-read-1', 'find'), - actions.savedObject.get('all-savedObject-read-2', 'bulk_get'), - actions.savedObject.get('all-savedObject-read-2', 'get'), - actions.savedObject.get('all-savedObject-read-2', 'find'), - actions.ui.get('foo', 'all-ui-1'), - actions.ui.get('foo', 'all-ui-2'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), - actions.savedObject.get('read-savedObject-all-1', 'get'), - actions.savedObject.get('read-savedObject-all-1', 'find'), - actions.savedObject.get('read-savedObject-all-1', 'create'), - actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), - actions.savedObject.get('read-savedObject-all-1', 'update'), - actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), - actions.savedObject.get('read-savedObject-all-1', 'delete'), - actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), - actions.savedObject.get('read-savedObject-all-2', 'get'), - actions.savedObject.get('read-savedObject-all-2', 'find'), - actions.savedObject.get('read-savedObject-all-2', 'create'), - actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), - actions.savedObject.get('read-savedObject-all-2', 'update'), - actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), - actions.savedObject.get('read-savedObject-all-2', 'delete'), - actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), - actions.savedObject.get('read-savedObject-read-1', 'get'), - actions.savedObject.get('read-savedObject-read-1', 'find'), - actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), - actions.savedObject.get('read-savedObject-read-2', 'get'), - actions.savedObject.get('read-savedObject-read-2', 'find'), - actions.ui.get('foo', 'read-ui-1'), - actions.ui.get('foo', 'read-ui-2'), - ], + all: [...expectedAllPrivileges], + read: [...expectedReadPrivileges], }); }); test(`features with no privileges aren't listed`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', app: [], - privileges: {}, - }, + privileges: null, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).not.toHaveProperty('features.foo'); @@ -276,82 +193,9 @@ describe('features', () => { }, ].forEach(({ group, expectManageSpaces, expectGetFeatures }) => { describe(`${group}`, () => { - test('actions defined only at the feature are included in `all` and `read`', () => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo Feature', - icon: 'arrowDown', - navLinkId: 'kibana:foo', - app: ['app-1', 'app-2'], - catalogue: ['catalogue-1', 'catalogue-2'], - management: { - foo: ['management-1', 'management-2'], - }, - privileges: { - all: { - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - read: { - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - }, - }, - ]; - - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), - }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); - - const actual = privileges.get(); - expect(actual).toHaveProperty(group, { - all: [ - actions.login, - actions.version, - ...(expectGetFeatures ? [actions.api.get('features')] : []), - ...(expectManageSpaces - ? [ - actions.space.manage, - actions.ui.get('spaces', 'manage'), - actions.ui.get('management', 'kibana', 'spaces'), - ] - : []), - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - ], - }); - }); - test('actions defined in any feature privilege are included in `all`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -362,17 +206,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - management: { - 'bar-management': ['bar-management-1', 'bar-management-2'], - }, - catalogue: ['bar-catalogue-1', 'bar-catalogue-2'], - savedObject: { - all: ['bar-savedObject-all-1', 'bar-savedObject-all-2'], - read: ['bar-savedObject-read-1', 'bar-savedObject-read-2'], - }, - ui: ['bar-ui-1', 'bar-ui-2'], - }, all: { management: { 'all-management': ['all-management-1', 'all-management-2'], @@ -396,14 +229,16 @@ describe('features', () => { ui: ['read-ui-1', 'read-ui-2'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -417,39 +252,11 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.ui.get('catalogue', 'bar-catalogue-1'), - actions.ui.get('catalogue', 'bar-catalogue-2'), - actions.ui.get('management', 'bar-management', 'bar-management-1'), - actions.ui.get('management', 'bar-management', 'bar-management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - actions.savedObject.get('bar-savedObject-all-1', 'bulk_get'), - actions.savedObject.get('bar-savedObject-all-1', 'get'), - actions.savedObject.get('bar-savedObject-all-1', 'find'), - actions.savedObject.get('bar-savedObject-all-1', 'create'), - actions.savedObject.get('bar-savedObject-all-1', 'bulk_create'), - actions.savedObject.get('bar-savedObject-all-1', 'update'), - actions.savedObject.get('bar-savedObject-all-1', 'bulk_update'), - actions.savedObject.get('bar-savedObject-all-1', 'delete'), - actions.savedObject.get('bar-savedObject-all-2', 'bulk_get'), - actions.savedObject.get('bar-savedObject-all-2', 'get'), - actions.savedObject.get('bar-savedObject-all-2', 'find'), - actions.savedObject.get('bar-savedObject-all-2', 'create'), - actions.savedObject.get('bar-savedObject-all-2', 'bulk_create'), - actions.savedObject.get('bar-savedObject-all-2', 'update'), - actions.savedObject.get('bar-savedObject-all-2', 'bulk_update'), - actions.savedObject.get('bar-savedObject-all-2', 'delete'), - actions.savedObject.get('bar-savedObject-read-1', 'bulk_get'), - actions.savedObject.get('bar-savedObject-read-1', 'get'), - actions.savedObject.get('bar-savedObject-read-1', 'find'), - actions.savedObject.get('bar-savedObject-read-2', 'bulk_get'), - actions.savedObject.get('bar-savedObject-read-2', 'get'), - actions.savedObject.get('bar-savedObject-read-2', 'find'), - actions.ui.get('foo', 'bar-ui-1'), - actions.ui.get('foo', 'bar-ui-2'), actions.ui.get('catalogue', 'all-catalogue-1'), actions.ui.get('catalogue', 'all-catalogue-2'), actions.ui.get('management', 'all-management', 'all-management-1'), actions.ui.get('management', 'all-management', 'all-management-2'), + actions.ui.get('navLinks', 'kibana:foo'), actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), actions.savedObject.get('all-savedObject-all-1', 'get'), actions.savedObject.get('all-savedObject-all-1', 'find'), @@ -502,13 +309,12 @@ describe('features', () => { actions.savedObject.get('read-savedObject-read-2', 'find'), actions.ui.get('foo', 'read-ui-1'), actions.ui.get('foo', 'read-ui-2'), - actions.allHack, ]); }); test('actions defined in a feature privilege with name `read` are included in `read`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -519,17 +325,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - management: { - 'ignore-me': ['ignore-me-1', 'ignore-me-2'], - }, - catalogue: ['ignore-me-1', 'ignore-me-2'], - savedObject: { - all: ['ignore-me-1', 'ignore-me-2'], - read: ['ignore-me-1', 'ignore-me-2'], - }, - ui: ['ignore-me-1', 'ignore-me-2'], - }, all: { management: { 'ignore-me': ['ignore-me-1', 'ignore-me-2'], @@ -553,14 +348,16 @@ describe('features', () => { ui: ['read-ui-1', 'read-ui-2'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.read`, [ @@ -600,7 +397,7 @@ describe('features', () => { test('actions defined in a reserved privilege are not included in `all` or `read`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -610,7 +407,7 @@ describe('features', () => { management: { foo: ['ignore-me-1', 'ignore-me-2'], }, - privileges: {}, + privileges: null, reserved: { privilege: { savedObject: { @@ -621,14 +418,16 @@ describe('features', () => { }, description: '', }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -642,14 +441,13 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.allHack, ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); test('actions defined in a feature with excludeFromBasePrivileges are not included in `all` or `read', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', excludeFromBasePrivileges: true, @@ -661,17 +459,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - management: { - 'bar-management': ['bar-management-1'], - }, - catalogue: ['bar-catalogue-1'], - savedObject: { - all: ['bar-savedObject-all-1'], - read: ['bar-savedObject-read-1'], - }, - ui: ['bar-ui-1'], - }, all: { management: { 'all-management': ['all-management-1'], @@ -695,14 +482,16 @@ describe('features', () => { ui: ['read-ui-1'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -716,14 +505,13 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.allHack, ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); test('actions defined in an individual feature privilege with excludeFromBasePrivileges are not included in `all` or `read`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -734,18 +522,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - excludeFromBasePrivileges: true, - management: { - 'bar-management': ['bar-management-1'], - }, - catalogue: ['bar-catalogue-1'], - savedObject: { - all: ['bar-savedObject-all-1'], - read: ['bar-savedObject-read-1'], - }, - ui: ['bar-ui-1'], - }, all: { excludeFromBasePrivileges: true, management: { @@ -771,14 +547,16 @@ describe('features', () => { ui: ['read-ui-1'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -792,7 +570,6 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.allHack, ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -800,9 +577,9 @@ describe('features', () => { }); describe('reserved', () => { - test('actions defined at the feature cascade to the privileges', () => { + test('actions defined at the feature do not cascade to the privileges', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -812,7 +589,7 @@ describe('reserved', () => { management: { foo: ['management-1', 'management-2'], }, - privileges: {}, + privileges: null, reserved: { privilege: { savedObject: { @@ -823,84 +600,32 @@ describe('reserved', () => { }, description: '', }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); - - const actual = privileges.get(); - expect(actual).toHaveProperty('reserved.foo', [ - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - ]); - }); - - test('actions defined at the reservedPrivilege take precedence', () => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo Feature', - icon: 'arrowDown', - app: ['ignore-me-1', 'ignore-me-2'], - catalogue: ['ignore-me-1', 'ignore-me-2'], - management: { - foo: ['ignore-me-1', 'ignore-me-2'], - }, - privileges: {}, - reserved: { - privilege: { - app: ['app-1', 'app-2'], - catalogue: ['catalogue-1', 'catalogue-2'], - management: { - bar: ['management-1', 'management-2'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - description: '', - }, - }, - ]; - - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty('reserved.foo', [ actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'bar', 'management-1'), - actions.ui.get('management', 'bar', 'management-2'), + actions.ui.get('navLinks', 'kibana:foo'), ]); }); test(`actions only specified at the privilege are alright too`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', app: [], - privileges: {}, + privileges: null, reserved: { privilege: { savedObject: { @@ -911,14 +636,16 @@ describe('reserved', () => { }, description: '', }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty('reserved.foo', [ @@ -952,7 +679,7 @@ describe('reserved', () => { test(`features with no reservedPrivileges aren't listed`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -965,17 +692,953 @@ describe('reserved', () => { }, ui: ['foo'], }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).not.toHaveProperty('reserved.foo'); }); }); + +describe('subFeatures', () => { + describe(`with includeIn: 'none'`, () => { + test(`should not augment the primary feature privileges, base privileges, or minimal feature privileges`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'none', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty('foo.all', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + expect(actual.features).toHaveProperty('foo.minimal_all', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty('foo.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + expect(actual.features).toHaveProperty('foo.minimal_read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('foo', 'foo'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + }); + }); + + describe(`with includeIn: 'read'`, () => { + test(`should augment the primary feature privileges and base privileges, but never the minimal versions`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + }); + + test(`should augment the primary feature privileges, but not base privileges if feature is excluded from them.`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + excludeFromBasePrivileges: true, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + ]); + expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); + + expect(actual).toHaveProperty('space.all', [actions.login, actions.version]); + expect(actual).toHaveProperty('space.read', [actions.login, actions.version]); + }); + }); + + describe(`with includeIn: 'all'`, () => { + test(`should augment the primary 'all' feature privileges and base 'all' privileges, but never the minimal versions`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'all', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + }); + + test(`should augment the primary 'all' feature privileges, but not the base privileges if the feature is excluded from them`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + excludeFromBasePrivileges: true, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'all', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + ]); + expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); + + expect(actual).toHaveProperty('space.all', [actions.login, actions.version]); + expect(actual).toHaveProperty('space.read', [actions.login, actions.version]); + }); + }); + + describe(`when license does not allow sub features`, () => { + test(`should augment the primary feature privileges, and should not create minimal or sub-feature privileges`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: false }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).not.toHaveProperty(`foo.subFeaturePriv1`); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).not.toHaveProperty(`foo.minimal_all`); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).not.toHaveProperty(`foo.minimal_read`); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index c73c4be8f36ac8..b25aad30a34238 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -4,65 +4,94 @@ * you may not use this file except in compliance with the Elastic License. */ -import { flatten, mapValues, uniq } from 'lodash'; +import { uniq } from 'lodash'; +import { SecurityLicense } from '../../../common/licensing'; import { Feature } from '../../../../features/server'; -import { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from '../../../common/model'; +import { RawKibanaPrivileges } from '../../../common/model'; import { Actions } from '../actions'; import { featurePrivilegeBuilderFactory } from './feature_privilege_builder'; import { FeaturesService } from '../../plugin'; +import { + featurePrivilegeIterator, + subFeaturePrivilegeIterator, +} from './feature_privilege_iterator'; export interface PrivilegesService { get(): RawKibanaPrivileges; } -export function privilegesFactory(actions: Actions, featuresService: FeaturesService) { +export function privilegesFactory( + actions: Actions, + featuresService: FeaturesService, + licenseService: Pick +) { const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); return { get() { const features = featuresService.getFeatures(); + const { allowSubFeaturePrivileges } = licenseService.getFeatures(); const basePrivilegeFeatures = features.filter(feature => !feature.excludeFromBasePrivileges); - const allActions = uniq( - flatten( - basePrivilegeFeatures.map(feature => - Object.values(feature.privileges).reduce((acc, privilege) => { - if (privilege.excludeFromBasePrivileges) { - return acc; - } + let allActions: string[] = []; + let readActions: string[] = []; - return [...acc, ...featurePrivilegeBuilder.getActions(privilege, feature)]; - }, []) - ) - ) - ); + basePrivilegeFeatures.forEach(feature => { + for (const { privilegeId, privilege } of featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + predicate: (pId, featurePrivilege) => !featurePrivilege.excludeFromBasePrivileges, + })) { + const privilegeActions = featurePrivilegeBuilder.getActions(privilege, feature); + allActions = [...allActions, ...privilegeActions]; + if (privilegeId === 'read') { + readActions = [...readActions, ...privilegeActions]; + } + } + }); - const readActions = uniq( - flatten( - basePrivilegeFeatures.map(feature => - Object.entries(feature.privileges).reduce((acc, [privilegeId, privilege]) => { - if (privilegeId !== 'read' || privilege.excludeFromBasePrivileges) { - return acc; - } + allActions = uniq(allActions); + readActions = uniq(readActions); - return [...acc, ...featurePrivilegeBuilder.getActions(privilege, feature)]; - }, []) - ) - ) - ); + const featurePrivileges: Record> = {}; + for (const feature of features) { + featurePrivileges[feature.id] = {}; + for (const featurePrivilege of featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + })) { + featurePrivileges[feature.id][featurePrivilege.privilegeId] = [ + actions.login, + actions.version, + ...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)), + ]; + } - return { - features: features.reduce((acc: RawKibanaFeaturePrivileges, feature: Feature) => { - if (Object.keys(feature.privileges).length > 0) { - acc[feature.id] = mapValues(feature.privileges, (privilege, privilegeId) => [ + if (allowSubFeaturePrivileges && feature.subFeatures?.length > 0) { + for (const featurePrivilege of featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: false, + })) { + featurePrivileges[feature.id][`minimal_${featurePrivilege.privilegeId}`] = [ actions.login, actions.version, - ...featurePrivilegeBuilder.getActions(privilege, feature), - ...(privilegeId === 'all' ? [actions.allHack] : []), - ]); + ...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)), + ]; } - return acc; - }, {}), + + for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature)) { + featurePrivileges[feature.id][subFeaturePrivilege.id] = [ + actions.login, + actions.version, + ...uniq(featurePrivilegeBuilder.getActions(subFeaturePrivilege, feature)), + ]; + } + } + + if (Object.keys(featurePrivileges[feature.id]).length === 0) { + delete featurePrivileges[feature.id]; + } + } + + return { + features: featurePrivileges, global: { all: [ actions.login, @@ -72,12 +101,11 @@ export function privilegesFactory(actions: Actions, featuresService: FeaturesSer actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), ...allActions, - actions.allHack, ], read: [actions.login, actions.version, ...readActions], }, space: { - all: [actions.login, actions.version, ...allActions, actions.allHack], + all: [actions.login, actions.version, ...allActions], read: [actions.login, actions.version, ...readActions], }, reserved: features.reduce((acc: Record, feature: Feature) => { diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts index 3dc3ae03b18cbf..ac386d287cff19 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts @@ -5,13 +5,42 @@ */ import { Feature } from '../../../features/server'; -import { Actions } from './actions'; import { validateFeaturePrivileges } from './validate_feature_privileges'; -const actions = new Actions('1.0.0-zeta1'); +it('allows features to be defined without privileges', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + }); -it(`doesn't allow read to grant privileges which aren't also included in all`, () => { - const feature: Feature = { + validateFeaturePrivileges([feature]); +}); + +it('allows features with reserved privileges to be defined', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + reserved: { + description: 'foo', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, + }, + }); + + validateFeaturePrivileges([feature]); +}); + +it('allows features with sub-features to be defined', () => { + const feature: Feature = new Feature({ id: 'foo', name: 'foo', app: [], @@ -31,15 +60,50 @@ it(`doesn't allow read to grant privileges which aren't also included in all`, ( ui: [], }, }, - }; + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-1-priv-1', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-1-priv-2', + name: 'some second sub feature', + includeIn: 'none', + savedObject: { + all: ['foo', 'bar'], + read: ['baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); - expect(() => validateFeaturePrivileges(actions, [feature])).toThrowErrorMatchingInlineSnapshot( - `"foo's \\"all\\" privilege should be a superset of the \\"read\\" privilege."` - ); + validateFeaturePrivileges([feature]); }); -it(`allows all and read to grant the same privileges`, () => { - const feature: Feature = { +it('does not allow features with sub-features which have id conflicts with the minimal privileges', () => { + const feature: Feature = new Feature({ id: 'foo', name: 'foo', app: [], @@ -54,18 +118,42 @@ it(`allows all and read to grant the same privileges`, () => { read: { savedObject: { all: ['foo'], - read: ['bar'], + read: ['bar', 'baz'], }, ui: [], }, }, - }; + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'minimal_all', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); - validateFeaturePrivileges(actions, [feature]); + expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( + `"Feature 'foo' already has a privilege with ID 'minimal_all'. Sub feature 'sub-feature-1' cannot also specify this."` + ); }); -it(`allows all to grant privileges in addition to read`, () => { - const feature: Feature = { +it('does not allow features with sub-features which have id conflicts with the primary feature privileges', () => { + const feature: Feature = new Feature({ id: 'foo', name: 'foo', app: [], @@ -73,19 +161,113 @@ it(`allows all to grant privileges in addition to read`, () => { all: { savedObject: { all: ['foo'], - read: ['bar', 'baz'], + read: ['bar'], }, ui: [], }, read: { + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'read', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); + + expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( + `"Feature 'foo' already has a privilege with ID 'read'. Sub feature 'sub-feature-1' cannot also specify this."` + ); +}); + +it('does not allow features with sub-features which have id conflicts each other', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { savedObject: { all: ['foo'], read: ['bar'], }, ui: [], }, + read: { + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, }, - }; + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'some-sub-feature', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + { + name: 'sub-feature-2', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'some-sub-feature', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); - validateFeaturePrivileges(actions, [feature]); + expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( + `"Feature 'foo' already has a privilege with ID 'some-sub-feature'. Sub feature 'sub-feature-2' cannot also specify this."` + ); }); diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts index 7998c816ae1c72..510feb1151a9bd 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts @@ -5,21 +5,27 @@ */ import { Feature } from '../../../features/server'; -import { areActionsFullyCovered } from '../../common/privilege_calculator_utils'; -import { Actions } from './actions'; -import { featurePrivilegeBuilderFactory } from './privileges/feature_privilege_builder'; -export function validateFeaturePrivileges(actions: Actions, features: Feature[]) { - const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); +export function validateFeaturePrivileges(features: Feature[]) { for (const feature of features) { - if (feature.privileges.all != null && feature.privileges.read != null) { - const allActions = featurePrivilegeBuilder.getActions(feature.privileges.all, feature); - const readActions = featurePrivilegeBuilder.getActions(feature.privileges.read, feature); - if (!areActionsFullyCovered(allActions, readActions)) { - throw new Error( - `${feature.id}'s "all" privilege should be a superset of the "read" privilege.` - ); - } - } + const seenPrivilegeIds = new Set(); + Object.keys(feature.privileges ?? {}).forEach(privilegeId => { + seenPrivilegeIds.add(privilegeId); + seenPrivilegeIds.add(`minimal_${privilegeId}`); + }); + + const subFeatureEntries = feature.subFeatures ?? []; + subFeatureEntries.forEach(subFeature => { + subFeature.privilegeGroups.forEach(subFeaturePrivilegeGroup => { + subFeaturePrivilegeGroup.privileges.forEach(subFeaturePrivilege => { + if (seenPrivilegeIds.has(subFeaturePrivilege.id)) { + throw new Error( + `Feature '${feature.id}' already has a privilege with ID '${subFeaturePrivilege.id}'. Sub feature '${subFeature.name}' cannot also specify this.` + ); + } + seenPrivilegeIds.add(subFeaturePrivilege.id); + }); + }); + }); } } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index a23c826b32fbd6..4767f57de764c2 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -82,7 +82,6 @@ describe('Security Plugin', () => { }, "authz": Object { "actions": Actions { - "allHack": "allHack:", "api": ApiActions { "prefix": "api:version:", }, diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index 9217d5a437f9c4..7751f9a952c090 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -163,6 +163,7 @@ describe('Login view routes', () => { layout: 'error-es-unavailable', showLinks: false, showRoleMappingsManagement: true, + allowSubFeaturePrivileges: true, showLogin: true, }); diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap index 7db3d5456fbd32..6d40ce15fc57f3 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap @@ -91,14 +91,14 @@ exports[`EnabledFeatures renders as expected 1`] = ` "icon": "spacesApp", "id": "feature-1", "name": "Feature 1", - "privileges": Object {}, + "privileges": null, }, Object { "app": Array [], "icon": "spacesApp", "id": "feature-2", "name": "Feature 2", - "privileges": Object {}, + "privileges": null, }, ] } diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx index d9282ad0457dd1..ca53a9eb172535 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx @@ -10,22 +10,22 @@ import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { Space } from '../../../../common/model/space'; import { SectionPanel } from '../section_panel'; import { EnabledFeatures } from './enabled_features'; -import { Feature } from '../../../../../features/public'; +import { FeatureConfig } from '../../../../../features/public'; -const features: Feature[] = [ +const features: FeatureConfig[] = [ { id: 'feature-1', name: 'Feature 1', icon: 'spacesApp', app: [], - privileges: {}, + privileges: null, }, { id: 'feature-2', name: 'Feature 2', icon: 'spacesApp', app: [], - privileges: {}, + privileges: null, }, ]; diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx index 52a0fe8d4d26c4..6f0462a6ddcc23 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment, ReactNode } from 'react'; -import { Feature } from '../../../../../../plugins/features/public'; +import { FeatureConfig } from '../../../../../../plugins/features/public'; import { Space } from '../../../../common/model/space'; import { getEnabledFeatures } from '../../lib/feature_utils'; import { SectionPanel } from '../section_panel'; @@ -16,7 +16,7 @@ import { FeatureTable } from './feature_table'; interface Props { space: Partial; - features: Feature[]; + features: FeatureConfig[]; securityEnabled: boolean; onChange: (space: Partial) => void; } diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx index 380f151b54a18f..880842ed0ae301 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx @@ -9,13 +9,13 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { ChangeEvent, Component } from 'react'; -import { Feature } from '../../../../../../plugins/features/public'; +import { FeatureConfig } from '../../../../../../plugins/features/public'; import { Space } from '../../../../common/model/space'; import { ToggleAllFeatures } from './toggle_all_features'; interface Props { space: Partial; - features: Feature[]; + features: FeatureConfig[]; onChange: (space: Partial) => void; } @@ -69,7 +69,10 @@ export class FeatureTable extends Component { name: i18n.translate('xpack.spaces.management.enabledSpaceFeaturesFeatureColumnTitle', { defaultMessage: 'Feature', }), - render: (feature: Feature, _item: { feature: Feature; space: Props['space'] }) => { + render: ( + feature: FeatureConfig, + _item: { feature: FeatureConfig; space: Props['space'] } + ) => { return ( diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx index 2aba1522a7e3fc..b79bbd0d6ab3f3 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx @@ -13,7 +13,9 @@ import { ManageSpacePage } from './manage_space_page'; import { SectionPanel } from './section_panel'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; -import { httpServiceMock, notificationServiceMock } from 'src/core/public/mocks'; +import { notificationServiceMock } from 'src/core/public/mocks'; +import { featuresPluginMock } from '../../../../features/public/mocks'; +import { Feature } from '../../../../features/public'; const space = { id: 'my-space', @@ -21,19 +23,27 @@ const space = { disabledFeatures: [], }; +const featuresStart = featuresPluginMock.createStart(); +featuresStart.getFeatures.mockResolvedValue([ + new Feature({ + id: 'feature-1', + name: 'feature 1', + icon: 'spacesApp', + app: [], + privileges: null, + }), +]); + describe('ManageSpacePage', () => { it('allows a space to be created', async () => { const spacesManager = spacesManagerMock.create(); spacesManager.createSpace = jest.fn(spacesManager.createSpace); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const wrapper = mountWithIntl( { }); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const onLoadSpace = jest.fn(); const wrapper = mountWithIntl( @@ -93,7 +100,7 @@ describe('ManageSpacePage', () => { spaceId={'existing-space'} spacesManager={(spacesManager as unknown) as SpacesManager} onLoadSpace={onLoadSpace} - http={httpStart} + getFeatures={featuresStart.getFeatures} notifications={notificationServiceMock.createStartContract()} securityEnabled={true} capabilities={{ @@ -130,6 +137,37 @@ describe('ManageSpacePage', () => { }); }); + it('notifies when there is an error retrieving features', async () => { + const spacesManager = spacesManagerMock.create(); + spacesManager.createSpace = jest.fn(spacesManager.createSpace); + spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); + + const error = new Error('something awful happened'); + + const notifications = notificationServiceMock.createStartContract(); + + const wrapper = mountWithIntl( + Promise.reject(error)} + notifications={notifications} + securityEnabled={true} + capabilities={{ + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }} + /> + ); + + await waitForDataLoad(wrapper); + + expect(notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error loading available features', + }); + }); + it('warns when updating features in the active space', async () => { const spacesManager = spacesManagerMock.create(); spacesManager.getSpace = jest.fn().mockResolvedValue({ @@ -142,14 +180,11 @@ describe('ManageSpacePage', () => { }); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const wrapper = mountWithIntl( { }); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const wrapper = mountWithIntl( { return; } - const { spaceId, http } = this.props; + const { spaceId, getFeatures, notifications } = this.props; - const getFeatures = http.get('/api/features'); - - if (spaceId) { - await this.loadSpace(spaceId, getFeatures); - } else { - const features = await getFeatures; - this.setState({ isLoading: false, features }); + try { + if (spaceId) { + await this.loadSpace(spaceId, getFeatures()); + } else { + const features = await getFeatures(); + this.setState({ isLoading: false, features }); + } + } catch (e) { + notifications.toasts.addError(e, { + title: i18n.translate('xpack.spaces.management.manageSpacePage.loadErrorTitle', { + defaultMessage: 'Error loading available features', + }), + }); } } @@ -318,7 +324,7 @@ export class ManageSpacePage extends Component { this.setState({ space, - features: await features, + features, originalSpace: space, isLoading: false, }); diff --git a/x-pack/plugins/spaces/public/management/lib/feature_utils.ts b/x-pack/plugins/spaces/public/management/lib/feature_utils.ts index a1b64eb954403a..09dbe886ab1919 100644 --- a/x-pack/plugins/spaces/public/management/lib/feature_utils.ts +++ b/x-pack/plugins/spaces/public/management/lib/feature_utils.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../features/common'; +import { FeatureConfig } from '../../../../features/common'; import { Space } from '../..'; -export function getEnabledFeatures(features: Feature[], space: Partial) { +export function getEnabledFeatures(features: FeatureConfig[], space: Partial) { return features.filter(feature => !(space.disabledFeatures || []).includes(feature.id)); } diff --git a/x-pack/plugins/spaces/public/management/management_service.test.ts b/x-pack/plugins/spaces/public/management/management_service.test.ts index d4c6bdaea2776e..782c261be9664c 100644 --- a/x-pack/plugins/spaces/public/management/management_service.test.ts +++ b/x-pack/plugins/spaces/public/management/management_service.test.ts @@ -10,6 +10,8 @@ import { spacesManagerMock } from '../spaces_manager/mocks'; import { managementPluginMock } from '../../../../../src/plugins/management/public/mocks'; import { ManagementSection } from 'src/plugins/management/public'; import { Capabilities } from 'kibana/public'; +import { PluginsStart } from '../plugin'; +import { CoreSetup } from 'src/core/public'; describe('ManagementService', () => { describe('#setup', () => { @@ -19,7 +21,9 @@ describe('ManagementService', () => { } as unknown) as ManagementSection; const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -43,7 +47,9 @@ describe('ManagementService', () => { it('will not crash if the kibana section is missing', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -61,7 +67,9 @@ describe('ManagementService', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -88,7 +96,9 @@ describe('ManagementService', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -117,7 +127,9 @@ describe('ManagementService', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx index 4cc4190e9591b1..ff4be842078324 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx @@ -20,8 +20,8 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Capabilities, HttpStart, NotificationsStart } from 'src/core/public'; -import { Feature } from '../../../../features/public'; +import { Capabilities, NotificationsStart } from 'src/core/public'; +import { Feature, FeaturesPluginStart } from '../../../../features/public'; import { isReservedSpace } from '../../../common'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { Space } from '../../../common/model/space'; @@ -36,7 +36,7 @@ import { getEnabledFeatures } from '../lib/feature_utils'; interface Props { spacesManager: SpacesManager; notifications: NotificationsStart; - http: HttpStart; + getFeatures: FeaturesPluginStart['getFeatures']; capabilities: Capabilities; securityEnabled: boolean; } @@ -47,7 +47,6 @@ interface State { loading: boolean; showConfirmDeleteModal: boolean; selectedSpace: Space | null; - error: Error | null; } export class SpacesGridPage extends Component { @@ -59,7 +58,6 @@ export class SpacesGridPage extends Component { loading: true, showConfirmDeleteModal: false, selectedSpace: null, - error: null, }; } @@ -211,7 +209,7 @@ export class SpacesGridPage extends Component { }; public loadGrid = async () => { - const { spacesManager, http } = this.props; + const { spacesManager, getFeatures, notifications } = this.props; this.setState({ loading: true, @@ -220,10 +218,9 @@ export class SpacesGridPage extends Component { }); const getSpaces = spacesManager.getSpaces(); - const getFeatures = http.get('/api/features'); try { - const [spaces, features] = await Promise.all([getSpaces, getFeatures]); + const [spaces, features] = await Promise.all([getSpaces, getFeatures()]); this.setState({ loading: false, spaces, @@ -232,7 +229,11 @@ export class SpacesGridPage extends Component { } catch (error) { this.setState({ loading: false, - error, + }); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.spaces.management.spacesGridPage.errorTitle', { + defaultMessage: 'Error loading spaces', + }), }); } }; diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx index 90c7aba65e3d6f..9b7dc921b9a256 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx @@ -12,6 +12,8 @@ import { SpacesManager } from '../../spaces_manager'; import { SpacesGridPage } from './spaces_grid_page'; import { httpServiceMock } from 'src/core/public/mocks'; import { notificationServiceMock } from 'src/core/public/mocks'; +import { featuresPluginMock } from '../../../../features/public/mocks'; +import { Feature } from '../../../../features/public'; const spaces = [ { @@ -38,6 +40,17 @@ const spaces = [ const spacesManager = spacesManagerMock.create(); spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces); +const featuresStart = featuresPluginMock.createStart(); +featuresStart.getFeatures.mockResolvedValue([ + new Feature({ + id: 'feature-1', + name: 'feature 1', + icon: 'spacesApp', + app: [], + privileges: null, + }), +]); + describe('SpacesGridPage', () => { it('renders as expected', () => { const httpStart = httpServiceMock.createStartContract(); @@ -47,7 +60,7 @@ describe('SpacesGridPage', () => { shallowWithIntl( { const wrapper = mountWithIntl( { expect(wrapper.find(SpaceAvatar)).toHaveLength(spaces.length); expect(wrapper.find(SpaceAvatar)).toMatchSnapshot(); }); + + it('notifies when spaces fail to load', async () => { + const httpStart = httpServiceMock.createStartContract(); + httpStart.get.mockResolvedValue([]); + + const error = new Error('something awful happened'); + spacesManager.getSpaces.mockRejectedValue(error); + + const notifications = notificationServiceMock.createStartContract(); + + const wrapper = mountWithIntl( + + ); + + // allow spacesManager to load spaces + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SpaceAvatar)).toHaveLength(0); + expect(notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error loading spaces', + }); + }); + + it('notifies when features fail to load', async () => { + const httpStart = httpServiceMock.createStartContract(); + httpStart.get.mockResolvedValue([]); + + const error = new Error('something awful happened'); + + const notifications = notificationServiceMock.createStartContract(); + + const wrapper = mountWithIntl( + Promise.reject(error)} + notifications={notifications} + securityEnabled={true} + capabilities={{ + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }} + /> + ); + + // allow spacesManager to load spaces + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SpaceAvatar)).toHaveLength(0); + // For end-users, the effect is that spaces won't load, even though this was a request to retrieve features. + expect(notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error loading spaces', + }); + }); }); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx index 2e274e08ee13b6..7738a440cb5e1d 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx @@ -23,6 +23,8 @@ import { coreMock } from '../../../../../src/core/public/mocks'; import { securityMock } from '../../../security/public/mocks'; import { spacesManagerMock } from '../spaces_manager/mocks'; import { SecurityLicenseFeatures } from '../../../security/public'; +import { featuresPluginMock } from '../../../features/public/mocks'; +import { PluginsStart } from '../plugin'; async function mountApp(basePath: string, spaceId?: string) { const container = document.createElement('div'); @@ -42,11 +44,14 @@ async function mountApp(basePath: string, spaceId?: string) { showLinks: true, } as SecurityLicenseFeatures); + const [coreStart, pluginsStart] = await coreMock.createSetup().getStartServices(); + (pluginsStart as PluginsStart).features = featuresPluginMock.createStart(); + const unmount = await spacesManagementApp .create({ spacesManager, securityLicense, - getStartServices: coreMock.createSetup().getStartServices as any, + getStartServices: async () => [coreStart, pluginsStart as PluginsStart], }) .mount({ basePath, element: container, setBreadcrumbs }); @@ -81,7 +86,7 @@ describe('spacesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `#${basePath}`, text: 'Spaces' }]); expect(container).toMatchInlineSnapshot(`
- Spaces Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true} + Spaces Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true}
`); @@ -103,7 +108,7 @@ describe('spacesManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true} + Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true}
`); @@ -126,7 +131,7 @@ describe('spacesManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"spaceId":"some-space","securityEnabled":true} + Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"spaceId":"some-space","securityEnabled":true}
`); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx index 2a93e684bb7169..92b369807b0dab 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -33,7 +33,10 @@ export const spacesManagementApp = Object.freeze({ defaultMessage: 'Spaces', }), async mount({ basePath, element, setBreadcrumbs }) { - const [{ http, notifications, i18n: i18nStart, application }] = await getStartServices(); + const [ + { notifications, i18n: i18nStart, application }, + { features }, + ] = await getStartServices(); const spacesBreadcrumbs = [ { text: i18n.translate('xpack.spaces.management.breadcrumb', { @@ -48,7 +51,7 @@ export const spacesManagementApp = Object.freeze({ return ( { describe('#setup', () => { @@ -101,7 +102,7 @@ describe('Spaces plugin', () => { const plugin = new SpacesPlugin(); plugin.setup(coreSetup, {}); - plugin.start(coreStart, {}); + plugin.start(coreStart, { features: featuresPluginMock.createStart() }); expect(coreStart.chrome.navControls.registerLeft).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 44215ec5380025..876ab39df3a1f1 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -9,6 +9,7 @@ import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { SavedObjectsManagementAction } from 'src/legacy/core_plugins/management/public'; import { ManagementStart, ManagementSetup } from 'src/plugins/management/public'; import { AdvancedSettingsSetup } from 'src/plugins/advanced_settings/public'; +import { FeaturesPluginStart } from '../../features/public'; import { SecurityPluginStart, SecurityPluginSetup } from '../../security/public'; import { SpacesManager } from './spaces_manager'; import { initSpacesNavControl } from './nav_control'; @@ -26,6 +27,7 @@ export interface PluginsSetup { } export interface PluginsStart { + features: FeaturesPluginStart; management?: ManagementStart; security?: SecurityPluginStart; } @@ -53,7 +55,7 @@ export class SpacesPlugin implements Plugin['getStartServices'], spacesManager: this.spacesManager, securityLicense: plugins.security?.license, }); diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts index fcd756c2aca10b..2c1ab26dd3d82e 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -13,12 +13,11 @@ import { featuresPluginMock } from '../../../features/server/mocks'; import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; import { PluginsStart } from '../plugin'; -const features: Feature[] = [ +const features = ([ { id: 'feature_1', name: 'Feature 1', app: [], - privileges: {}, }, { id: 'feature_2', @@ -60,7 +59,7 @@ const features: Feature[] = [ }, }, }, -]; +] as unknown) as Feature[]; const buildCapabilities = () => Object.freeze({ diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f02b52e5922d1c..09392093b8f628 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10429,7 +10429,6 @@ "xpack.security.management.editRole.elasticSearchPrivileges.runAsPrivilegesTitle": "権限として実行", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesEnabledColumnTitle": "権限", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle": "機能", - "xpack.security.management.editRole.featureTable.excludedFromBasePrivilegsTooltip": "アクセスを許可するには、「カスタム」特権を使用します。{featureName} は基本権限の一部ではありません。", "xpack.security.management.editRole.indexPrivilegeForm.deleteSpacePrivilegeAriaLabel": "インデックスの権限を削除", "xpack.security.management.editRole.indexPrivilegeForm.grantedDocumentsQueryFormRowLabel": "提供されたドキュメントのクエリ", "xpack.security.management.editRole.indexPrivilegeForm.grantReadPrivilegesLabel": "特定のドキュメントの読み込み権限を提供", @@ -10459,13 +10458,6 @@ "xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeInput": "読み込み", "xpack.security.management.editRole.simplePrivilegeForm.specifyPrivilegeForRoleDescription": "このロールの Kibana の権限を指定します。", "xpack.security.management.editRole.simplePrivilegeForm.unsupportedSpacePrivilegesWarning": "このロールはスペースへの権限が定義されていますが、Kibana でスペースが有効ではありません。このロールを保存するとこれらの権限が削除されます。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.effectivePrivilegeMessage": "{source} で許可されています。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalBasePrivilegeSource": "グローバルベース権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalFeaturePrivilegeSource": "グローバル機能権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.privilegeSupercededMessage": "{supersededPrivilege} のオリジナルの権限は {actualPrivilegeSource} により上書きされています", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceBasePrivilegeSource": "スペースベース権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceFeaturePrivilegeSource": "スペース機能権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.unknownPrivilegeSource": "**不明**", "xpack.security.management.editRole.spaceAwarePrivilegeForm.ensureAccountHasAllPrivilegesGrantedDescription": "{kibanaAdmin} ロールによりアカウントにすべての権限が提供されていることを確認し、再試行してください。", "xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName": "* グローバル (すべてのスペース)", "xpack.security.management.editRole.spaceAwarePrivilegeForm.howToViewAllAvailableSpacesDescription": "利用可能なすべてのスペースを表示する権限がありません。", @@ -10489,15 +10481,9 @@ "xpack.security.management.editRole.spacePrivilegeForm.readPrivilegeDropdownDisplay": "読み込み", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormLabel": "スペース", "xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges": "機能権限のサマリー", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeText": "ベース権限", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeTooltip": "基本権限は自動的にすべての機能に与えられます。", - "xpack.security.management.editRole.spacePrivilegeMatrix.closeButton": "閉じる", - "xpack.security.management.editRole.spacePrivilegeMatrix.featureColumnTitle": "機能", "xpack.security.management.editRole.spacePrivilegeMatrix.globalSpaceName": "グローバル", - "xpack.security.management.editRole.spacePrivilegeMatrix.modalTitle": "権限のサマリー", "xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink": "(すべてのスペース)", "xpack.security.management.editRole.spacePrivilegeMatrix.showNMoreSpacesLink": "他 {count} 件", - "xpack.security.management.editRole.spacePrivilegeMatrix.showSummaryText": "権限サマリーを表示", "xpack.security.management.editRole.spacePrivilegeSection.addSpacePrivilegeButton": "スペース権限を追加", "xpack.security.management.editRole.spacePrivilegeSection.noAccessToKibanaTitle": "このロールは Kibana へのアクセスを許可しません", "xpack.security.management.editRole.spacePrivilegeTable.deletePrivilegesLabel": "次のスペースの権限を削除: {spaceNames}", @@ -10524,7 +10510,6 @@ "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowHelpText": "フィールドが提供されていない場合、このロールのユーザーはこのインデックスのデータを表示できません。", "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowLabel": "許可されたフィールド", "xpack.security.management.editRoles.indexPrivilegeForm.grantFieldPrivilegesLabel": "特定のフィールドへのアクセスを許可", - "xpack.security.management.editRolespacePrivilegeForm.cancelButton": "キャンセル", "xpack.security.management.editRolespacePrivilegeForm.createGlobalPrivilegeButton": "グローバル権限を作成", "xpack.security.management.editRolespacePrivilegeForm.createPrivilegeButton": "スペース権限を作成", "xpack.security.management.editRolespacePrivilegeForm.updateGlobalPrivilegeButton": "グローバル特権を更新", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index be6a3df6b6c187..a3382a19f76b7b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10429,7 +10429,6 @@ "xpack.security.management.editRole.elasticSearchPrivileges.runAsPrivilegesTitle": "运行身份权限", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesEnabledColumnTitle": "权限", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle": "功能", - "xpack.security.management.editRole.featureTable.excludedFromBasePrivilegsTooltip": "使用“定制”权限来授予权限。{featureName} 不属于基础权限。", "xpack.security.management.editRole.indexPrivilegeForm.deleteSpacePrivilegeAriaLabel": "删除索引权限", "xpack.security.management.editRole.indexPrivilegeForm.grantedDocumentsQueryFormRowLabel": "已授权文档查询", "xpack.security.management.editRole.indexPrivilegeForm.grantReadPrivilegesLabel": "授予特定文档的读取权限", @@ -10459,13 +10458,6 @@ "xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeInput": "读取", "xpack.security.management.editRole.simplePrivilegeForm.specifyPrivilegeForRoleDescription": "为此角色指定 Kibana 权限。", "xpack.security.management.editRole.simplePrivilegeForm.unsupportedSpacePrivilegesWarning": "此角色包含工作区的权限定义,但在 Kibana 中未启用工作区。保存此角色将会移除这些权限。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.effectivePrivilegeMessage": "已通过 {source} 授予。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalBasePrivilegeSource": "全局基本权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalFeaturePrivilegeSource": "全局功能权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.privilegeSupercededMessage": "{supersededPrivilege} 的原始权限已为 {actualPrivilegeSource} 所覆盖", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceBasePrivilegeSource": "工作区基本权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceFeaturePrivilegeSource": "全局功能权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.unknownPrivilegeSource": "**未知**", "xpack.security.management.editRole.spaceAwarePrivilegeForm.ensureAccountHasAllPrivilegesGrantedDescription": "请确保您的帐户具有 {kibanaAdmin} 角色授予的所有权限,然后重试。", "xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName": "* 全局(所有工作区)", "xpack.security.management.editRole.spaceAwarePrivilegeForm.howToViewAllAvailableSpacesDescription": "您无权查看所有可用工作区。", @@ -10489,15 +10481,9 @@ "xpack.security.management.editRole.spacePrivilegeForm.readPrivilegeDropdownDisplay": "读取", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormLabel": "工作区", "xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges": "功能权限的摘要", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeText": "基本权限", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeTooltip": "所有功能的基本权限将自动授予。", - "xpack.security.management.editRole.spacePrivilegeMatrix.closeButton": "关闭", - "xpack.security.management.editRole.spacePrivilegeMatrix.featureColumnTitle": "功能", "xpack.security.management.editRole.spacePrivilegeMatrix.globalSpaceName": "全局", - "xpack.security.management.editRole.spacePrivilegeMatrix.modalTitle": "权限摘要", "xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink": "(所有工作区)", "xpack.security.management.editRole.spacePrivilegeMatrix.showNMoreSpacesLink": "另外 {count} 个", - "xpack.security.management.editRole.spacePrivilegeMatrix.showSummaryText": "查看权限摘要", "xpack.security.management.editRole.spacePrivilegeSection.addSpacePrivilegeButton": "添加工作区权限", "xpack.security.management.editRole.spacePrivilegeSection.noAccessToKibanaTitle": "此角色未授予对 Kibana 的访问权限", "xpack.security.management.editRole.spacePrivilegeTable.deletePrivilegesLabel": "删除以下工作区的权限:{spaceNames}。", @@ -10524,7 +10510,6 @@ "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowHelpText": "如果未授权任何字段,则分配到此角色的用户将无法查看此索引的任何数据。", "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowLabel": "已授权字段", "xpack.security.management.editRoles.indexPrivilegeForm.grantFieldPrivilegesLabel": "授予对特定字段的访问权限", - "xpack.security.management.editRolespacePrivilegeForm.cancelButton": "取消", "xpack.security.management.editRolespacePrivilegeForm.createGlobalPrivilegeButton": "创建全局权限", "xpack.security.management.editRolespacePrivilegeForm.createPrivilegeButton": "创建工作区权限", "xpack.security.management.editRolespacePrivilegeForm.updateGlobalPrivilegeButton": "更新全局权限", diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index 19506bb316a056..da208e13acdad0 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -32,12 +32,15 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor features.registerFeature({ id: PLUGIN.ID, name: PLUGIN.NAME, + order: 1000, navLinkId: PLUGIN.ID, icon: 'uptimeApp', app: ['uptime', 'kibana'], catalogue: ['uptime'], privileges: { all: { + app: ['uptime', 'kibana'], + catalogue: ['uptime'], api: ['uptime-read', 'uptime-write'], savedObject: { all: [umDynamicSettings.name], @@ -46,6 +49,8 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor ui: ['save', 'configureSettings', 'show'], }, read: { + app: ['uptime', 'kibana'], + catalogue: ['uptime'], api: ['uptime-read'], savedObject: { all: [], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts index acd14e8a2bf7b0..019b15cc1862a4 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts @@ -57,6 +57,7 @@ export default function(kibana: any) { app: ['actions', 'kibana'], privileges: { all: { + app: ['actions', 'kibana'], savedObject: { all: ['action', 'action_task_params'], read: [], @@ -65,6 +66,7 @@ export default function(kibana: any) { api: ['actions-read', 'actions-all'], }, read: { + app: ['actions', 'kibana'], savedObject: { all: ['action_task_params'], read: ['action'], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts index 9b4a2d14de9ea1..fe0f630830a561 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts @@ -20,6 +20,7 @@ export default function(kibana: any) { app: ['alerting', 'kibana'], privileges: { all: { + app: ['alerting', 'kibana'], savedObject: { all: ['alert'], read: [], @@ -28,6 +29,7 @@ export default function(kibana: any) { api: ['alerting-read', 'alerting-all'], }, read: { + app: ['alerting', 'kibana'], savedObject: { all: [], read: ['alert'], diff --git a/x-pack/test/api_integration/apis/security/index.js b/x-pack/test/api_integration/apis/security/index.js index df35ec2195dc55..ad1876cb717f12 100644 --- a/x-pack/test/api_integration/apis/security/index.js +++ b/x-pack/test/api_integration/apis/security/index.js @@ -8,6 +8,9 @@ export default function({ loadTestFile }) { describe('security', function() { this.tags('ciGroup6'); + // Updates here should be mirrored in `./security_basic.ts` if tests + // should also run under a basic license. + loadTestFile(require.resolve('./basic_login')); loadTestFile(require.resolve('./builtin_es_privileges')); loadTestFile(require.resolve('./change_password')); diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 0b29fc1cac7de7..77293ddff3f9f6 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -5,6 +5,8 @@ */ import util from 'util'; import { isEqual } from 'lodash'; +import expect from '@kbn/expect/expect.js'; +import { RawKibanaPrivileges } from '../../../../plugins/security/common/model'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService }: FtrProviderContext) { @@ -18,9 +20,9 @@ export default function({ getService }: FtrProviderContext) { // Roles are associated with these privileges, and we shouldn't be removing them in a minor version. const expected = { features: { - discover: ['all', 'read'], - visualize: ['all', 'read'], - dashboard: ['all', 'read'], + discover: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'], + visualize: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'], + dashboard: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'], dev_tools: ['all', 'read'], advancedSettings: ['all', 'read'], indexPatterns: ['all', 'read'], @@ -48,13 +50,18 @@ export default function({ getService }: FtrProviderContext) { .send() .expect(200) .expect((res: any) => { - // when comparing privileges, the order of the privileges doesn't matter. + // when comparing privileges, the order of the features doesn't matter (but the order of the privileges does) // supertest uses assert.deepStrictEqual. // expect.js doesn't help us here. // and lodash's isEqual doesn't know how to compare Sets. const success = isEqual(res.body, expected, (value, other, key) => { if (Array.isArray(value) && Array.isArray(other)) { - return isEqual(value.sort(), other.sort()); + if (key === 'reserved') { + // order does not matter for the reserved privilege set. + return isEqual(value.sort(), other.sort()); + } + // order matters for the rest, as the UI assumes they are returned in a descending order of permissiveness. + return isEqual(value, other); } // Lodash types aren't correct, `undefined` should be supported as a return value here and it @@ -71,5 +78,70 @@ export default function({ getService }: FtrProviderContext) { .expect(200); }); }); + + describe('GET /api/security/privileges?includeActions=true', () => { + // The UI assumes that no wildcards are present when calculating the effective set of privileges. + // If this changes, then the "privilege calculators" will need revisiting to account for these wildcards. + it('should return a privilege map with actions which do not include wildcards', async () => { + await supertest + .get('/api/security/privileges?includeActions=true') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200) + .expect((res: any) => { + const { features, global, space, reserved } = res.body as RawKibanaPrivileges; + expect(features).to.be.an('object'); + expect(global).to.be.an('object'); + expect(space).to.be.an('object'); + expect(reserved).to.be.an('object'); + + Object.entries(features).forEach(([featureId, featurePrivs]) => { + Object.values(featurePrivs).forEach(actions => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Feature ${featureId} with action ${action} cannot contain a wildcard` + ); + }); + }); + }); + + Object.entries(global).forEach(([privilegeId, actions]) => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Global privilege ${privilegeId} with action ${action} cannot contain a wildcard` + ); + }); + }); + + Object.entries(space).forEach(([privilegeId, actions]) => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Space privilege ${privilegeId} with action ${action} cannot contain a wildcard` + ); + }); + }); + + Object.entries(reserved).forEach(([privilegeId, actions]) => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Reserved privilege ${privilegeId} with action ${action} cannot contain a wildcard` + ); + }); + }); + }); + }); + }); }); } diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts new file mode 100644 index 00000000000000..0b29fc1cac7de7 --- /dev/null +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -0,0 +1,75 @@ +/* + * 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 util from 'util'; +import { isEqual } from 'lodash'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Privileges', () => { + describe('GET /api/security/privileges', () => { + it('should return a privilege map with all known privileges, without actions', async () => { + // If you're adding a privilege to the following, that's great! + // If you're removing a privilege, this breaks backwards compatibility + // Roles are associated with these privileges, and we shouldn't be removing them in a minor version. + const expected = { + features: { + discover: ['all', 'read'], + visualize: ['all', 'read'], + dashboard: ['all', 'read'], + dev_tools: ['all', 'read'], + advancedSettings: ['all', 'read'], + indexPatterns: ['all', 'read'], + savedObjectsManagement: ['all', 'read'], + timelion: ['all', 'read'], + graph: ['all', 'read'], + maps: ['all', 'read'], + canvas: ['all', 'read'], + infrastructure: ['all', 'read'], + logs: ['all', 'read'], + uptime: ['all', 'read'], + apm: ['all', 'read'], + siem: ['all', 'read'], + endpoint: ['all', 'read'], + ingestManager: ['all', 'read'], + }, + global: ['all', 'read'], + space: ['all', 'read'], + reserved: ['ml', 'monitoring'], + }; + + await supertest + .get('/api/security/privileges') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200) + .expect((res: any) => { + // when comparing privileges, the order of the privileges doesn't matter. + // supertest uses assert.deepStrictEqual. + // expect.js doesn't help us here. + // and lodash's isEqual doesn't know how to compare Sets. + const success = isEqual(res.body, expected, (value, other, key) => { + if (Array.isArray(value) && Array.isArray(other)) { + return isEqual(value.sort(), other.sort()); + } + + // Lodash types aren't correct, `undefined` should be supported as a return value here and it + // has special meaning. + return undefined as any; + }); + + if (!success) { + throw new Error( + `Expected ${util.inspect(res.body)} to equal ${util.inspect(expected)}` + ); + } + }) + .expect(200); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/security/security_basic.ts b/x-pack/test/api_integration/apis/security/security_basic.ts new file mode 100644 index 00000000000000..dcbdb17724249b --- /dev/null +++ b/x-pack/test/api_integration/apis/security/security_basic.ts @@ -0,0 +1,24 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('security (basic license)', function() { + this.tags('ciGroup6'); + + // Updates here should be mirrored in `./index.js` if tests + // should also run under a trial/platinum license. + + loadTestFile(require.resolve('./basic_login')); + loadTestFile(require.resolve('./builtin_es_privileges')); + loadTestFile(require.resolve('./change_password')); + loadTestFile(require.resolve('./index_fields')); + loadTestFile(require.resolve('./roles')); + loadTestFile(require.resolve('./privileges_basic')); + loadTestFile(require.resolve('./session')); + }); +} diff --git a/x-pack/test/api_integration/config_security_basic.js b/x-pack/test/api_integration/config_security_basic.js index c427bf7fa8f28f..d21bfa4d7031a7 100644 --- a/x-pack/test/api_integration/config_security_basic.js +++ b/x-pack/test/api_integration/config_security_basic.js @@ -14,7 +14,7 @@ export default async function({ readConfigFile }) { 'xpack.license.self_generated.type=basic', 'xpack.security.enabled=true', ]; - config.testFiles = [require.resolve('./apis/security')]; + config.testFiles = [require.resolve('./apis/security/security_basic')]; return config; }); } diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index b966d37becc3f0..de68ec0c64c17c 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -338,6 +338,115 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('global dashboard read-only with url_create privileges', () => { + before(async () => { + await security.role.create('global_dashboard_read_url_create_role', { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + dashboard: ['read', 'url_create'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_dashboard_read_url_create_user', { + password: 'global_dashboard_read_url_create_user-password', + roles: ['global_dashboard_read_url_create_role'], + full_name: 'test user', + }); + + await PageObjects.security.login( + 'global_dashboard_read_url_create_user', + 'global_dashboard_read_url_create_user-password', + { + expectSpaceSelector: false, + } + ); + }); + + after(async () => { + await security.role.delete('global_dashboard_read_url_create_role'); + await security.user.delete('global_dashboard_read_url_create_user'); + }); + + it('shows dashboard navlink', async () => { + const navLinks = (await appsMenu.readLinks()).map(link => link.text); + expect(navLinks).to.eql(['Dashboard', 'Management']); + }); + + it(`landing page doesn't show "Create new Dashboard" button`, async () => { + await PageObjects.common.navigateToActualUrl( + 'kibana', + DashboardConstants.LANDING_PAGE_PATH, + { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + } + ); + await testSubjects.existOrFail('dashboardLandingPage', { timeout: 10000 }); + await testSubjects.missingOrFail('newItemButton'); + }); + + it(`shows read-only badge`, async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + it(`create new dashboard redirects to the home page`, async () => { + await PageObjects.common.navigateToActualUrl( + 'kibana', + DashboardConstants.CREATE_NEW_DASHBOARD_URL, + { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + } + ); + await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + }); + + it(`can view existing Dashboard`, async () => { + await PageObjects.common.navigateToActualUrl('kibana', createDashboardEditUrl('i-exist'), { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + }); + await testSubjects.existOrFail('embeddablePanelHeading-APie', { timeout: 10000 }); + }); + + it(`Permalinks shows create short-url button`, async () => { + await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.createShortUrlExistOrFail(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); + }); + describe('no dashboard privileges', () => { before(async () => { await security.role.create('no_dashboard_privileges_role', { diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index 98ab4c1f15a546..dc8c488460100f 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -221,6 +221,97 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('global discover read-only privileges with url_create', () => { + before(async () => { + await security.role.create('global_discover_read_url_create_role', { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + discover: ['read', 'url_create'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_discover_read_url_create_user', { + password: 'global_discover_read_url_create_user-password', + roles: ['global_discover_read_url_create_role'], + full_name: 'test user', + }); + + await PageObjects.security.login( + 'global_discover_read_url_create_user', + 'global_discover_read_url_create_user-password', + { + expectSpaceSelector: false, + } + ); + }); + + after(async () => { + await security.user.delete('global_discover_read_url_create_user'); + await security.role.delete('global_discover_read_url_create_role'); + }); + + it('shows discover navlink', async () => { + const navLinks = (await appsMenu.readLinks()).map(link => link.text); + expect(navLinks).to.eql(['Discover', 'Management']); + }); + + it(`doesn't show save button`, async () => { + await PageObjects.common.navigateToApp('discover'); + await testSubjects.existOrFail('discoverNewButton', { timeout: 10000 }); + await testSubjects.missingOrFail('discoverSaveButton'); + }); + + it(`shows read-only badge`, async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + it(`doesn't show visualize button`, async () => { + await PageObjects.common.navigateToApp('discover'); + await setDiscoverTimeRange(); + await PageObjects.discover.clickFieldListItem('bytes'); + await PageObjects.discover.expectMissingFieldListItemVisualize('bytes'); + }); + + it('Permalinks shows create short-url button', async () => { + await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.createShortUrlExistOrFail(); + // close the menu + await PageObjects.share.clickShareTopNavButton(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); + }); + describe('discover and visualize privileges', () => { before(async () => { await security.role.create('global_discover_visualize_read_role', { diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index e5b6512d1c1b07..9f080a056e91fe 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -276,6 +276,113 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('global visualize read-only with url_create privileges', () => { + before(async () => { + await security.role.create('global_visualize_read_url_create_role', { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + visualize: ['read', 'url_create'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_visualize_read_url_create_user', { + password: 'global_visualize_read_url_create_user-password', + roles: ['global_visualize_read_url_create_role'], + full_name: 'test user', + }); + + await PageObjects.security.login( + 'global_visualize_read_url_create_user', + 'global_visualize_read_url_create_user-password', + { + expectSpaceSelector: false, + } + ); + }); + + after(async () => { + await PageObjects.security.forceLogout(); + await security.role.delete('global_visualize_read_url_create_role'); + await security.user.delete('global_visualize_read_url_create_user'); + }); + + it('shows visualize navlink', async () => { + const navLinks = (await appsMenu.readLinks()).map(link => link.text); + expect(navLinks).to.eql(['Visualize', 'Management']); + }); + + it(`landing page shows "Create new Visualization" button`, async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await testSubjects.existOrFail('visualizeLandingPage', { timeout: 10000 }); + await testSubjects.existOrFail('newItemButton'); + }); + + it(`shows read-only badge`, async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + it(`can view existing Visualization`, async () => { + await PageObjects.common.navigateToActualUrl('visualize', '/visualize/edit/i-exist', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + }); + await testSubjects.existOrFail('visualizationLoader', { timeout: 10000 }); + }); + + it(`can't save existing Visualization`, async () => { + await PageObjects.common.navigateToActualUrl('visualize', '/visualize/edit/i-exist', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + }); + await testSubjects.existOrFail('shareTopNavButton', { timeout: 10000 }); + await testSubjects.missingOrFail('visualizeSaveButton', { timeout: 10000 }); + }); + + it('Embed code shows create short-url button', async () => { + await PageObjects.share.openShareMenuItem('Embedcode'); + await PageObjects.share.createShortUrlExistOrFail(); + }); + + it('Permalinks shows create short-url button', async () => { + await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.createShortUrlExistOrFail(); + // close menu + await PageObjects.share.clickShareTopNavButton(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); + }); + describe('no visualize privileges', () => { before(async () => { await security.role.create('no_visualize_privileges_role', { diff --git a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js index 6110996a553dc8..89ae0125614b67 100644 --- a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js +++ b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js @@ -28,6 +28,8 @@ export default function(kibana) { catalogue: ['foo'], privileges: { all: { + app: ['kibana'], + catalogue: ['foo'], savedObject: { all: ['foo'], read: ['index-pattern'], @@ -35,6 +37,8 @@ export default function(kibana) { ui: ['create', 'edit', 'delete', 'show'], }, read: { + app: ['kibana'], + catalogue: ['foo'], savedObject: { all: [], read: ['foo', 'index-pattern'],