From 8edfd14da5be93005d86f16d458ab8bbc8be4567 Mon Sep 17 00:00:00 2001 From: Maryia Lapata Date: Tue, 24 Mar 2020 17:52:16 +0300 Subject: [PATCH 01/12] [NP] Graph: get rid of saved objects class wrapper (#59917) (#61057) * Implement find saved workspace * Implement get saved workspace * Create helper function as applyESResp * Fix eslint * Implement savedWorkspaceLoader.get() * Implement deleteWS * Implement saveWS * Remove applyESRespUtil * Refactoring * Refactoring * Remove savedWorkspaceLoader * Update unit test * Fix merge conflicts * Add unit tests for saveWithConfirmation * Fix TS * Move saveWithConfirmation to a separate file Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- src/plugins/saved_objects/public/index.ts | 8 +- .../saved_object/helpers/save_saved_object.ts | 2 +- .../helpers/save_with_confirmation.test.ts | 93 ++++++++ .../helpers/save_with_confirmation.ts | 87 ++++++++ .../public/saved_object/index.ts | 3 + x-pack/plugins/graph/public/app.js | 26 ++- x-pack/plugins/graph/public/application.ts | 11 +- .../public/helpers/saved_workspace_utils.ts | 207 ++++++++++++++++++ .../services/persistence/saved_workspace.ts | 68 ------ .../persistence/saved_workspace_loader.ts | 69 ------ .../saved_workspace_references.test.ts | 9 +- .../persistence/saved_workspace_references.ts | 6 +- .../graph/public/services/save_modal.tsx | 14 +- .../graph/public/state_management/mocks.ts | 14 +- .../state_management/persistence.test.ts | 5 +- .../public/state_management/persistence.ts | 11 +- .../graph/public/state_management/store.ts | 4 +- .../plugins/graph/public/types/persistence.ts | 17 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 20 files changed, 478 insertions(+), 180 deletions(-) create mode 100644 src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts create mode 100644 src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts create mode 100644 x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts delete mode 100644 x-pack/plugins/graph/public/services/persistence/saved_workspace.ts delete mode 100644 x-pack/plugins/graph/public/services/persistence/saved_workspace_loader.ts diff --git a/src/plugins/saved_objects/public/index.ts b/src/plugins/saved_objects/public/index.ts index ea92c921efad0..9e0a7c40c043f 100644 --- a/src/plugins/saved_objects/public/index.ts +++ b/src/plugins/saved_objects/public/index.ts @@ -21,7 +21,13 @@ import { SavedObjectsPublicPlugin } from './plugin'; export { OnSaveProps, SavedObjectSaveModal, SaveResult, showSaveModal } from './save_modal'; export { getSavedObjectFinder, SavedObjectFinderUi, SavedObjectMetaData } from './finder'; -export { SavedObjectLoader, createSavedObjectClass } from './saved_object'; +export { + SavedObjectLoader, + createSavedObjectClass, + checkForDuplicateTitle, + saveWithConfirmation, + isErrorNonFatal, +} from './saved_object'; export { SavedObjectSaveOpts, SavedObjectKibanaServices, SavedObject } from './types'; export const plugin = () => new SavedObjectsPublicPlugin(); diff --git a/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts b/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts index e43619c2692a5..1fe3e551b09bc 100644 --- a/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts +++ b/src/plugins/saved_objects/public/saved_object/helpers/save_saved_object.ts @@ -30,7 +30,7 @@ import { checkForDuplicateTitle } from './check_for_duplicate_title'; * @param error {Error} the error * @return {boolean} */ -function isErrorNonFatal(error: { message: string }) { +export function isErrorNonFatal(error: { message: string }) { if (!error) return false; return error.message === OVERWRITE_REJECTED || error.message === SAVE_DUPLICATE_REJECTED; } diff --git a/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts new file mode 100644 index 0000000000000..b05747a10ecb7 --- /dev/null +++ b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.test.ts @@ -0,0 +1,93 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SavedObjectAttributes, SavedObjectsCreateOptions, OverlayStart } from 'kibana/public'; +import { SavedObjectsClientContract } from '../../../../../core/public'; +import { saveWithConfirmation } from './save_with_confirmation'; +import * as deps from './confirm_modal_promise'; +import { OVERWRITE_REJECTED } from '../../constants'; + +describe('saveWithConfirmation', () => { + const savedObjectsClient: SavedObjectsClientContract = {} as SavedObjectsClientContract; + const overlays: OverlayStart = {} as OverlayStart; + const source: SavedObjectAttributes = {} as SavedObjectAttributes; + const options: SavedObjectsCreateOptions = {} as SavedObjectsCreateOptions; + const savedObject = { + getEsType: () => 'test type', + title: 'test title', + displayName: 'test display name', + }; + + beforeEach(() => { + savedObjectsClient.create = jest.fn(); + jest.spyOn(deps, 'confirmModalPromise').mockReturnValue(Promise.resolve({} as any)); + }); + + test('should call create of savedObjectsClient', async () => { + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + savedObject.getEsType(), + source, + options + ); + }); + + test('should call confirmModalPromise when such record exists', async () => { + savedObjectsClient.create = jest + .fn() + .mockImplementation((type, src, opt) => + opt && opt.overwrite ? Promise.resolve({} as any) : Promise.reject({ res: { status: 409 } }) + ); + + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(deps.confirmModalPromise).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + overlays + ); + }); + + test('should call create of savedObjectsClient when overwriting confirmed', async () => { + savedObjectsClient.create = jest + .fn() + .mockImplementation((type, src, opt) => + opt && opt.overwrite ? Promise.resolve({} as any) : Promise.reject({ res: { status: 409 } }) + ); + + await saveWithConfirmation(source, savedObject, options, { savedObjectsClient, overlays }); + expect(savedObjectsClient.create).toHaveBeenLastCalledWith(savedObject.getEsType(), source, { + overwrite: true, + ...options, + }); + }); + + test('should reject when overwriting denied', async () => { + savedObjectsClient.create = jest.fn().mockReturnValue(Promise.reject({ res: { status: 409 } })); + jest.spyOn(deps, 'confirmModalPromise').mockReturnValue(Promise.reject()); + + expect.assertions(1); + await expect( + saveWithConfirmation(source, savedObject, options, { + savedObjectsClient, + overlays, + }) + ).rejects.toThrow(OVERWRITE_REJECTED); + }); +}); diff --git a/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts new file mode 100644 index 0000000000000..b413ea19a932d --- /dev/null +++ b/src/plugins/saved_objects/public/saved_object/helpers/save_with_confirmation.ts @@ -0,0 +1,87 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { get } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + SavedObjectAttributes, + SavedObjectsCreateOptions, + OverlayStart, + SavedObjectsClientContract, +} from 'kibana/public'; +import { OVERWRITE_REJECTED } from '../../constants'; +import { confirmModalPromise } from './confirm_modal_promise'; + +/** + * Attempts to create the current object using the serialized source. If an object already + * exists, a warning message requests an overwrite confirmation. + * @param source - serialized version of this object what will be indexed into elasticsearch. + * @param savedObject - a simple object that contains properties title and displayName, and getEsType method + * @param options - options to pass to the saved object create method + * @param services - provides Kibana services savedObjectsClient and overlays + * @returns {Promise} - A promise that is resolved with the objects id if the object is + * successfully indexed. If the overwrite confirmation was rejected, an error is thrown with + * a confirmRejected = true parameter so that case can be handled differently than + * a create or index error. + * @resolved {SavedObject} + */ +export async function saveWithConfirmation( + source: SavedObjectAttributes, + savedObject: { + getEsType(): string; + title: string; + displayName: string; + }, + options: SavedObjectsCreateOptions, + services: { savedObjectsClient: SavedObjectsClientContract; overlays: OverlayStart } +) { + const { savedObjectsClient, overlays } = services; + try { + return await savedObjectsClient.create(savedObject.getEsType(), source, options); + } catch (err) { + // record exists, confirm overwriting + if (get(err, 'res.status') === 409) { + const confirmMessage = i18n.translate( + 'savedObjects.confirmModal.overwriteConfirmationMessage', + { + defaultMessage: 'Are you sure you want to overwrite {title}?', + values: { title: savedObject.title }, + } + ); + + const title = i18n.translate('savedObjects.confirmModal.overwriteTitle', { + defaultMessage: 'Overwrite {name}?', + values: { name: savedObject.displayName }, + }); + const confirmButtonText = i18n.translate('savedObjects.confirmModal.overwriteButtonLabel', { + defaultMessage: 'Overwrite', + }); + + return confirmModalPromise(confirmMessage, title, confirmButtonText, overlays) + .then(() => + savedObjectsClient.create(savedObject.getEsType(), source, { + overwrite: true, + ...options, + }) + ) + .catch(() => Promise.reject(new Error(OVERWRITE_REJECTED))); + } + return await Promise.reject(err); + } +} diff --git a/src/plugins/saved_objects/public/saved_object/index.ts b/src/plugins/saved_objects/public/saved_object/index.ts index d3be5ea6df617..178ffaf88f4be 100644 --- a/src/plugins/saved_objects/public/saved_object/index.ts +++ b/src/plugins/saved_objects/public/saved_object/index.ts @@ -19,3 +19,6 @@ export { createSavedObjectClass } from './saved_object'; export { SavedObjectLoader } from './saved_object_loader'; +export { checkForDuplicateTitle } from './helpers/check_for_duplicate_title'; +export { saveWithConfirmation } from './helpers/save_with_confirmation'; +export { isErrorNonFatal } from './helpers/save_saved_object'; diff --git a/x-pack/plugins/graph/public/app.js b/x-pack/plugins/graph/public/app.js index 72dddc2b9f813..53175d18e629f 100644 --- a/x-pack/plugins/graph/public/app.js +++ b/x-pack/plugins/graph/public/app.js @@ -31,6 +31,11 @@ import { asAngularSyncedObservable } from './helpers/as_observable'; import { colorChoices } from './helpers/style_choices'; import { createGraphStore, datasourceSelector, hasFieldsSelector } from './state_management'; import { formatHttpError } from './helpers/format_http_error'; +import { + findSavedWorkspace, + getSavedWorkspace, + deleteSavedWorkspace, +} from './helpers/saved_workspace_utils'; export function initGraphApp(angularModule, deps) { const { @@ -42,7 +47,6 @@ export function initGraphApp(angularModule, deps) { getBasePath, data, config, - savedWorkspaceLoader, capabilities, coreStart, storage, @@ -112,15 +116,21 @@ export function initGraphApp(angularModule, deps) { $location.url(getNewPath()); }; $scope.find = search => { - return savedWorkspaceLoader.find(search, $scope.listingLimit); + return findSavedWorkspace( + { savedObjectsClient, basePath: coreStart.http.basePath }, + search, + $scope.listingLimit + ); }; $scope.editItem = workspace => { $location.url(getEditPath(workspace)); }; $scope.getViewUrl = workspace => getEditUrl(addBasePath, workspace); - $scope.delete = workspaces => { - return savedWorkspaceLoader.delete(workspaces.map(({ id }) => id)); - }; + $scope.delete = workspaces => + deleteSavedWorkspace( + savedObjectsClient, + workspaces.map(({ id }) => id) + ); $scope.capabilities = capabilities; $scope.initialFilter = $location.search().filter || ''; $scope.coreStart = coreStart; @@ -133,7 +143,7 @@ export function initGraphApp(angularModule, deps) { resolve: { savedWorkspace: function($rootScope, $route, $location) { return $route.current.params.id - ? savedWorkspaceLoader.get($route.current.params.id).catch(function(e) { + ? getSavedWorkspace(savedObjectsClient, $route.current.params.id).catch(function(e) { toastNotifications.addError(e, { title: i18n.translate('xpack.graph.missingWorkspaceErrorMessage', { defaultMessage: "Couldn't load graph with ID", @@ -146,7 +156,7 @@ export function initGraphApp(angularModule, deps) { // return promise that never returns to prevent the controller from loading return new Promise(); }) - : savedWorkspaceLoader.get(); + : getSavedWorkspace(savedObjectsClient); }, indexPatterns: function() { return savedObjectsClient @@ -283,6 +293,8 @@ export function initGraphApp(angularModule, deps) { }, notifications: coreStart.notifications, http: coreStart.http, + overlays: coreStart.overlays, + savedObjectsClient, showSaveModal, setWorkspaceInitialized: () => { $scope.workspaceInitialized = true; diff --git a/x-pack/plugins/graph/public/application.ts b/x-pack/plugins/graph/public/application.ts index 4f7bdd69db356..f804265f1f5ab 100644 --- a/x-pack/plugins/graph/public/application.ts +++ b/x-pack/plugins/graph/public/application.ts @@ -29,7 +29,6 @@ import { Plugin as DataPlugin, IndexPatternsContract } from '../../../../src/plu import { LicensingPluginSetup } from '../../licensing/public'; import { checkLicense } from '../common/check_license'; import { NavigationPublicPluginStart as NavigationStart } from '../../../../src/plugins/navigation/public'; -import { createSavedWorkspacesLoader } from './services/persistence/saved_workspace_loader'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { addAppRedirectMessageToUrl, @@ -87,15 +86,7 @@ export const renderApp = ({ appBasePath, element, ...deps }: GraphDependencies) } }); - const savedWorkspaceLoader = createSavedWorkspacesLoader({ - chrome: deps.coreStart.chrome, - indexPatterns: deps.data.indexPatterns, - overlays: deps.coreStart.overlays, - savedObjectsClient: deps.coreStart.savedObjects.client, - basePath: deps.coreStart.http.basePath, - }); - - initGraphApp(graphAngularModule, { ...deps, savedWorkspaceLoader }); + initGraphApp(graphAngularModule, deps); const $injector = mountGraphApp(appBasePath, element); return () => { licenseSubscription.unsubscribe(); diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts new file mode 100644 index 0000000000000..2933e94b86e86 --- /dev/null +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -0,0 +1,207 @@ +/* + * 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 { cloneDeep, assign, defaults, forOwn } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + IBasePath, + OverlayStart, + SavedObjectsClientContract, + SavedObjectAttributes, +} from 'kibana/public'; + +import { + SavedObjectSaveOpts, + checkForDuplicateTitle, + saveWithConfirmation, + isErrorNonFatal, + SavedObjectKibanaServices, +} from '../../../../../src/plugins/saved_objects/public'; +import { + injectReferences, + extractReferences, +} from '../services/persistence/saved_workspace_references'; +import { SavedObjectNotFound } from '../../../../../src/plugins/kibana_utils/public'; +import { GraphWorkspaceSavedObject } from '../types'; + +const savedWorkspaceType = 'graph-workspace'; +const mapping: Record = { + title: 'text', + description: 'text', + numLinks: 'integer', + numVertices: 'integer', + version: 'integer', + wsState: 'json', +}; +const defaultsProps = { + title: i18n.translate('xpack.graph.savedWorkspace.workspaceNameTitle', { + defaultMessage: 'New Graph Workspace', + }), + numLinks: 0, + numVertices: 0, + wsState: '{}', + version: 1, +}; + +const urlFor = (basePath: IBasePath, id: string) => + basePath.prepend(`/app/graph#/workspace/${encodeURIComponent(id)}`); + +function mapHits(hit: { id: string; attributes: Record }, url: string) { + const source = hit.attributes; + source.id = hit.id; + source.url = url; + source.icon = 'fa-share-alt'; // looks like a graph + return source; +} + +interface SavedWorkspaceServices { + basePath: IBasePath; + savedObjectsClient: SavedObjectsClientContract; +} + +export function findSavedWorkspace( + { savedObjectsClient, basePath }: SavedWorkspaceServices, + searchString: string, + size: number = 100 +) { + return savedObjectsClient + .find>({ + type: savedWorkspaceType, + search: searchString ? `${searchString}*` : undefined, + perPage: size, + searchFields: ['title^3', 'description'], + }) + .then(resp => { + return { + total: resp.total, + hits: resp.savedObjects.map(hit => mapHits(hit, urlFor(basePath, hit.id))), + }; + }); +} + +export async function getSavedWorkspace( + savedObjectsClient: SavedObjectsClientContract, + id?: string +) { + const savedObject = { + id, + displayName: 'graph workspace', + getEsType: () => savedWorkspaceType, + } as { [key: string]: any }; + + if (!id) { + assign(savedObject, defaultsProps); + return Promise.resolve(savedObject); + } + + const resp = await savedObjectsClient.get>(savedWorkspaceType, id); + savedObject._source = cloneDeep(resp.attributes); + + if (!resp._version) { + throw new SavedObjectNotFound(savedWorkspaceType, id || ''); + } + + // assign the defaults to the response + defaults(savedObject._source, defaultsProps); + + // transform the source using JSON.parse + if (savedObject._source.wsState) { + savedObject._source.wsState = JSON.parse(savedObject._source.wsState as string); + } + + // Give obj all of the values in _source.fields + assign(savedObject, savedObject._source); + savedObject.lastSavedTitle = savedObject.title; + + if (resp.references && resp.references.length > 0) { + injectReferences(savedObject, resp.references); + } + + return savedObject as GraphWorkspaceSavedObject; +} + +export function deleteSavedWorkspace( + savedObjectsClient: SavedObjectsClientContract, + ids: string[] +) { + return Promise.all(ids.map((id: string) => savedObjectsClient.delete(savedWorkspaceType, id))); +} + +export async function saveSavedWorkspace( + savedObject: GraphWorkspaceSavedObject, + { + confirmOverwrite = false, + isTitleDuplicateConfirmed = false, + onTitleDuplicate, + }: SavedObjectSaveOpts = {}, + services: { + savedObjectsClient: SavedObjectsClientContract; + overlays: OverlayStart; + } +) { + // Save the original id in case the save fails. + const originalId = savedObject.id; + // Read https://github.com/elastic/kibana/issues/9056 and + // https://github.com/elastic/kibana/issues/9012 for some background into why this copyOnSave variable + // exists. + // The goal is to move towards a better rename flow, but since our users have been conditioned + // to expect a 'save as' flow during a rename, we are keeping the logic the same until a better + // UI/UX can be worked out. + if (savedObject.copyOnSave) { + delete savedObject.id; + } + + let attributes: SavedObjectAttributes = {}; + + forOwn(mapping, (fieldType, fieldName) => { + const savedObjectFieldVal = savedObject[fieldName as keyof GraphWorkspaceSavedObject] as string; + if (savedObjectFieldVal != null) { + attributes[fieldName as keyof GraphWorkspaceSavedObject] = + fieldName === 'wsState' ? JSON.stringify(savedObjectFieldVal) : savedObjectFieldVal; + } + }); + const extractedRefs = extractReferences({ attributes, references: [] }); + const references = extractedRefs.references; + attributes = extractedRefs.attributes; + + if (!references) { + throw new Error('References not returned from extractReferences'); + } + + try { + await checkForDuplicateTitle( + savedObject as any, + isTitleDuplicateConfirmed, + onTitleDuplicate, + services as SavedObjectKibanaServices + ); + savedObject.isSaving = true; + + const createOpt = { + id: savedObject.id, + migrationVersion: savedObject.migrationVersion, + references, + }; + const resp = confirmOverwrite + ? await saveWithConfirmation(attributes, savedObject, createOpt, services) + : await services.savedObjectsClient.create(savedObject.getEsType(), attributes, { + ...createOpt, + overwrite: true, + }); + + savedObject.id = resp.id; + savedObject.isSaving = false; + savedObject.lastSavedTitle = savedObject.title; + return savedObject.id; + } catch (err) { + savedObject.isSaving = false; + savedObject.id = originalId; + if (isErrorNonFatal(err)) { + return ''; + } + return Promise.reject(err); + } +} diff --git a/x-pack/plugins/graph/public/services/persistence/saved_workspace.ts b/x-pack/plugins/graph/public/services/persistence/saved_workspace.ts deleted file mode 100644 index e2bd885dc7209..0000000000000 --- a/x-pack/plugins/graph/public/services/persistence/saved_workspace.ts +++ /dev/null @@ -1,68 +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 { i18n } from '@kbn/i18n'; -import { extractReferences, injectReferences } from './saved_workspace_references'; -import { - SavedObject, - createSavedObjectClass, - SavedObjectKibanaServices, -} from '../../../../../../src/plugins/saved_objects/public'; - -export interface SavedWorkspace extends SavedObject { - wsState?: string; -} - -export function createSavedWorkspaceClass(services: SavedObjectKibanaServices) { - // SavedWorkspace constructor. Usually you'd interact with an instance of this. - // ID is option, without it one will be generated on save. - const SavedObjectClass = createSavedObjectClass(services); - class SavedWorkspaceClass extends SavedObjectClass { - public static type: string = 'graph-workspace'; - // if type:workspace has no mapping, we push this mapping into ES - public static mapping: Record = { - title: 'text', - description: 'text', - numLinks: 'integer', - numVertices: 'integer', - version: 'integer', - wsState: 'json', - }; - // Order these fields to the top, the rest are alphabetical - public static fieldOrder = ['title', 'description']; - public static searchSource = false; - - public wsState?: string; - - constructor(id: string) { - // Gives our SavedWorkspace the properties of a SavedObject - super({ - type: SavedWorkspaceClass.type, - mapping: SavedWorkspaceClass.mapping, - searchSource: SavedWorkspaceClass.searchSource, - extractReferences, - injectReferences, - // if this is null/undefined then the SavedObject will be assigned the defaults - id, - // default values that will get assigned if the doc is new - defaults: { - title: i18n.translate('xpack.graph.savedWorkspace.workspaceNameTitle', { - defaultMessage: 'New Graph Workspace', - }), - numLinks: 0, - numVertices: 0, - wsState: '{}', - version: 1, - }, - }); - } - // Overwrite the default getDisplayName function which uses type and which is not very - // user friendly for this object. - getDisplayName = () => { - return 'graph workspace'; - }; - } - return SavedWorkspaceClass; -} diff --git a/x-pack/plugins/graph/public/services/persistence/saved_workspace_loader.ts b/x-pack/plugins/graph/public/services/persistence/saved_workspace_loader.ts deleted file mode 100644 index fb64fbadfbf7c..0000000000000 --- a/x-pack/plugins/graph/public/services/persistence/saved_workspace_loader.ts +++ /dev/null @@ -1,69 +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 { IBasePath } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; - -import { SavedObjectKibanaServices } from '../../../../../../src/plugins/saved_objects/public'; -import { createSavedWorkspaceClass } from './saved_workspace'; - -export function createSavedWorkspacesLoader( - services: SavedObjectKibanaServices & { basePath: IBasePath } -) { - const { savedObjectsClient, basePath } = services; - const SavedWorkspace = createSavedWorkspaceClass(services); - const urlFor = (id: string) => - basePath.prepend(`/app/graph#/workspace/${encodeURIComponent(id)}`); - const mapHits = (hit: { id: string; attributes: Record }) => { - const source = hit.attributes; - source.id = hit.id; - source.url = urlFor(hit.id); - source.icon = 'fa-share-alt'; // looks like a graph - return source; - }; - - return { - type: SavedWorkspace.type, - Class: SavedWorkspace, - loaderProperties: { - name: 'Graph workspace', - noun: i18n.translate('xpack.graph.savedWorkspaces.graphWorkspaceLabel', { - defaultMessage: 'Graph workspace', - }), - nouns: i18n.translate('xpack.graph.savedWorkspaces.graphWorkspacesLabel', { - defaultMessage: 'Graph workspaces', - }), - }, - // Returns a single dashboard by ID, should be the name of the workspace - get: (id: string) => { - // Returns a promise that contains a workspace which is a subclass of docSource - // @ts-ignore - return new SavedWorkspace(id).init(); - }, - urlFor, - delete: (ids: string | string[]) => { - const idArr = Array.isArray(ids) ? ids : [ids]; - return Promise.all( - idArr.map((id: string) => savedObjectsClient.delete(SavedWorkspace.type, id)) - ); - }, - find: (searchString: string, size: number = 100) => { - return savedObjectsClient - .find>({ - type: SavedWorkspace.type, - search: searchString ? `${searchString}*` : undefined, - perPage: size, - searchFields: ['title^3', 'description'], - }) - .then(resp => { - return { - total: resp.total, - hits: resp.savedObjects.map(hit => mapHits(hit)), - }; - }); - }, - }; -} diff --git a/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.test.ts b/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.test.ts index 716520cb83aa1..c973a54a650a6 100644 --- a/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.test.ts +++ b/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.test.ts @@ -5,7 +5,6 @@ */ import { extractReferences, injectReferences } from './saved_workspace_references'; -import { SavedWorkspace } from './saved_workspace'; describe('extractReferences', () => { test('extracts references from wsState', () => { @@ -67,7 +66,7 @@ describe('injectReferences', () => { indexPatternRefName: 'indexPattern_0', bar: true, }), - } as SavedWorkspace; + }; const references = [ { name: 'indexPattern_0', @@ -89,7 +88,7 @@ Object { const context = { id: '1', title: 'test', - } as SavedWorkspace; + } as any; injectReferences(context, []); expect(context).toMatchInlineSnapshot(` Object { @@ -103,7 +102,7 @@ Object { const context = { id: '1', wsState: JSON.stringify({ bar: true }), - } as SavedWorkspace; + }; injectReferences(context, []); expect(context).toMatchInlineSnapshot(` Object { @@ -119,7 +118,7 @@ Object { wsState: JSON.stringify({ indexPatternRefName: 'indexPattern_0', }), - } as SavedWorkspace; + }; expect(() => injectReferences(context, [])).toThrowErrorMatchingInlineSnapshot( `"Could not find reference \\"indexPattern_0\\""` ); diff --git a/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.ts b/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.ts index 3a596b8068655..0948d7a88fce8 100644 --- a/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.ts +++ b/x-pack/plugins/graph/public/services/persistence/saved_workspace_references.ts @@ -5,7 +5,6 @@ */ import { SavedObjectAttributes, SavedObjectReference } from 'kibana/public'; -import { SavedWorkspace } from './saved_workspace'; export function extractReferences({ attributes, @@ -38,7 +37,10 @@ export function extractReferences({ }; } -export function injectReferences(savedObject: SavedWorkspace, references: SavedObjectReference[]) { +export function injectReferences( + savedObject: { wsState?: string }, + references: SavedObjectReference[] +) { // Skip if wsState is missing, at the time of development of this, there is no guarantee each // saved object has wsState. if (typeof savedObject.wsState !== 'string') { diff --git a/x-pack/plugins/graph/public/services/save_modal.tsx b/x-pack/plugins/graph/public/services/save_modal.tsx index 127ff6a2b4c37..94b5de3be13ac 100644 --- a/x-pack/plugins/graph/public/services/save_modal.tsx +++ b/x-pack/plugins/graph/public/services/save_modal.tsx @@ -5,18 +5,24 @@ */ import React from 'react'; -import { I18nStart } from 'src/core/public'; +import { I18nStart, OverlayStart, SavedObjectsClientContract } from 'src/core/public'; import { SaveResult } from 'src/plugins/saved_objects/public'; import { GraphWorkspaceSavedObject, GraphSavePolicy } from '../types'; import { SaveModal, OnSaveGraphProps } from '../components/save_modal'; +export interface SaveWorkspaceServices { + overlays: OverlayStart; + savedObjectsClient: SavedObjectsClientContract; +} + export type SaveWorkspaceHandler = ( saveOptions: { confirmOverwrite: boolean; isTitleDuplicateConfirmed: boolean; onTitleDuplicate: () => void; }, - dataConsent: boolean + dataConsent: boolean, + services: SaveWorkspaceServices ) => Promise; export function openSaveModal({ @@ -26,6 +32,7 @@ export function openSaveModal({ saveWorkspace, showSaveModal, I18nContext, + services, }: { savePolicy: GraphSavePolicy; hasData: boolean; @@ -33,6 +40,7 @@ export function openSaveModal({ saveWorkspace: SaveWorkspaceHandler; showSaveModal: (el: React.ReactNode, I18nContext: I18nStart['Context']) => void; I18nContext: I18nStart['Context']; + services: SaveWorkspaceServices; }) { const currentTitle = workspace.title; const currentDescription = workspace.description; @@ -52,7 +60,7 @@ export function openSaveModal({ isTitleDuplicateConfirmed, onTitleDuplicate, }; - return saveWorkspace(saveOptions, dataConsent).then(response => { + return saveWorkspace(saveOptions, dataConsent, services).then(response => { // If the save wasn't successful, put the original values back. if (!('id' in response) || !Boolean(response.id)) { workspace.title = currentTitle; diff --git a/x-pack/plugins/graph/public/state_management/mocks.ts b/x-pack/plugins/graph/public/state_management/mocks.ts index d06f8a7b3ef0b..02a5830ffd6be 100644 --- a/x-pack/plugins/graph/public/state_management/mocks.ts +++ b/x-pack/plugins/graph/public/state_management/mocks.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { NotificationsStart, HttpStart } from 'kibana/public'; +import { + NotificationsStart, + HttpStart, + OverlayStart, + SavedObjectsClientContract, +} from 'kibana/public'; import createSagaMiddleware from 'redux-saga'; import { createStore, applyMiddleware, AnyAction } from 'redux'; import { ChromeStart } from 'kibana/public'; @@ -79,6 +84,13 @@ export function createMockGraphStore({ setLiveResponseFields: jest.fn(), setUrlTemplates: jest.fn(), setWorkspaceInitialized: jest.fn(), + overlays: ({ + openModal: jest.fn(), + } as unknown) as OverlayStart, + savedObjectsClient: ({ + find: jest.fn(), + get: jest.fn(), + } as unknown) as SavedObjectsClientContract, ...mockedDepsOverwrites, }; const sagaMiddleware = createSagaMiddleware(); diff --git a/x-pack/plugins/graph/public/state_management/persistence.test.ts b/x-pack/plugins/graph/public/state_management/persistence.test.ts index 2dac92fceb6b4..285bf2d6a0ea9 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.test.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.test.ts @@ -40,6 +40,10 @@ jest.mock('../services/save_modal', () => ({ openSaveModal: jest.fn(), })); +jest.mock('../helpers/saved_workspace_utils', () => ({ + saveSavedWorkspace: jest.fn().mockResolvedValueOnce('123'), +})); + describe('persistence sagas', () => { let env: MockedGraphEnvironment; @@ -90,7 +94,6 @@ describe('persistence sagas', () => { savePolicy: 'configAndDataWithConsent', }, }); - (env.mockedDeps.getSavedWorkspace().save as jest.Mock).mockResolvedValueOnce('123'); env.mockedDeps.getSavedWorkspace().id = '123'; }); diff --git a/x-pack/plugins/graph/public/state_management/persistence.ts b/x-pack/plugins/graph/public/state_management/persistence.ts index 0f72186af031f..8dd1386f70e6e 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.ts @@ -22,6 +22,8 @@ import { import { updateMetaData, metaDataSelector } from './meta_data'; import { openSaveModal, SaveWorkspaceHandler } from '../services/save_modal'; import { getEditPath } from '../services/url'; +import { saveSavedWorkspace } from '../helpers/saved_workspace_utils'; + const actionCreator = actionCreatorFactory('x-pack/graph'); export const loadSavedWorkspace = actionCreator('LOAD_WORKSPACE'); @@ -140,7 +142,8 @@ function showModal( ) { const saveWorkspaceHandler: SaveWorkspaceHandler = async ( saveOptions, - userHasConfirmedSaveWorkspaceData + userHasConfirmedSaveWorkspaceData, + services ) => { const canSaveData = deps.savePolicy === 'configAndData' || @@ -157,7 +160,7 @@ function showModal( canSaveData ); try { - const id = await savedWorkspace.save(saveOptions); + const id = await saveSavedWorkspace(savedWorkspace, saveOptions, services); if (id) { const title = i18n.translate('xpack.graph.saveWorkspace.successNotificationTitle', { defaultMessage: 'Saved "{workspaceTitle}"', @@ -200,5 +203,9 @@ function showModal( showSaveModal: deps.showSaveModal, saveWorkspace: saveWorkspaceHandler, I18nContext: deps.I18nContext, + services: { + savedObjectsClient: deps.savedObjectsClient, + overlays: deps.overlays, + }, }); } diff --git a/x-pack/plugins/graph/public/state_management/store.ts b/x-pack/plugins/graph/public/state_management/store.ts index 4aeef0338923b..e639662225cb3 100644 --- a/x-pack/plugins/graph/public/state_management/store.ts +++ b/x-pack/plugins/graph/public/state_management/store.ts @@ -6,7 +6,7 @@ import createSagaMiddleware, { SagaMiddleware } from 'redux-saga'; import { combineReducers, createStore, Store, AnyAction, Dispatch, applyMiddleware } from 'redux'; -import { ChromeStart, I18nStart } from 'kibana/public'; +import { ChromeStart, I18nStart, OverlayStart, SavedObjectsClientContract } from 'kibana/public'; import { CoreStart } from 'src/core/public'; import { fieldsReducer, @@ -54,6 +54,8 @@ export interface GraphStoreDependencies { getSavedWorkspace: () => GraphWorkspaceSavedObject; notifications: CoreStart['notifications']; http: CoreStart['http']; + overlays: OverlayStart; + savedObjectsClient: SavedObjectsClientContract; showSaveModal: (el: React.ReactNode, I18nContext: I18nStart['Context']) => void; savePolicy: GraphSavePolicy; changeUrl: (newUrl: string) => void; diff --git a/x-pack/plugins/graph/public/types/persistence.ts b/x-pack/plugins/graph/public/types/persistence.ts index b0209153c82e3..6847199d5878c 100644 --- a/x-pack/plugins/graph/public/types/persistence.ts +++ b/x-pack/plugins/graph/public/types/persistence.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObject } from '../../../../../src/plugins/saved_objects/public'; import { AdvancedSettings, UrlTemplate, WorkspaceField } from './app_state'; import { WorkspaceNode, WorkspaceEdge } from './workspace_state'; @@ -12,15 +11,23 @@ type Omit = Pick>; /** * Workspace fetched from server. - * This type is returned by `SavedWorkspacesProvider#get`. */ -export interface GraphWorkspaceSavedObject extends SavedObject { - title: string; +export interface GraphWorkspaceSavedObject { + copyOnSave?: boolean; description: string; + displayName: string; + getEsType(): string; + id?: string; + isSaving?: boolean; + lastSavedTitle?: string; + migrationVersion?: Record; numLinks: number; numVertices: number; - version: number; + title: string; + type: string; + version?: number; wsState: string; + _source: Record; } export interface SerializedWorkspaceState { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 9a44b48d250ed..8ada3576c7ba8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5701,8 +5701,6 @@ "xpack.graph.outlinkEncoders.textPlainTitle": "プレインテキスト", "xpack.graph.pluginDescription": "Elasticsearch データの関連性のある関係を浮上させ分析します。", "xpack.graph.savedWorkspace.workspaceNameTitle": "新規グラフワークスペース", - "xpack.graph.savedWorkspaces.graphWorkspaceLabel": "グラフワークスペース", - "xpack.graph.savedWorkspaces.graphWorkspacesLabel": "グラフワークスペース", "xpack.graph.saveWorkspace.savingErrorMessage": "ワークスペースの保存に失敗しました: {message}", "xpack.graph.saveWorkspace.successNotification.noDataSavedText": "構成が保存されましたが、データは保存されませんでした", "xpack.graph.saveWorkspace.successNotificationTitle": "「{workspaceTitle}」が保存されました", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 61b2a197b025b..6bf0b4bd6bb69 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5701,8 +5701,6 @@ "xpack.graph.outlinkEncoders.textPlainTitle": "纯文本", "xpack.graph.pluginDescription": "显示并分析 Elasticsearch 数据中的相关关系。", "xpack.graph.savedWorkspace.workspaceNameTitle": "新建 Graph 工作空间", - "xpack.graph.savedWorkspaces.graphWorkspaceLabel": "Graph 工作空间", - "xpack.graph.savedWorkspaces.graphWorkspacesLabel": "Graph 工作空间", "xpack.graph.saveWorkspace.savingErrorMessage": "无法保存工作空间:{message}", "xpack.graph.saveWorkspace.successNotification.noDataSavedText": "配置会被保存,但不保存数据", "xpack.graph.saveWorkspace.successNotificationTitle": "已保存“{workspaceTitle}”", From 76be57b1fdd79cf4e7dabf08c5432d81911116cf Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 24 Mar 2020 15:56:12 +0100 Subject: [PATCH 02/12] Upgrade mocha dev-dependency from 6.2.2 to 7.1.1 (#60779) (#61064) Co-authored-by: spalger --- package.json | 4 +- x-pack/package.json | 4 +- yarn.lock | 237 ++++++++++++++++++++------------------------ 3 files changed, 111 insertions(+), 134 deletions(-) diff --git a/package.json b/package.json index ac70640271b29..46f205da0e31f 100644 --- a/package.json +++ b/package.json @@ -343,7 +343,7 @@ "@types/lru-cache": "^5.1.0", "@types/markdown-it": "^0.0.7", "@types/minimatch": "^2.0.29", - "@types/mocha": "^5.2.7", + "@types/mocha": "^7.0.2", "@types/moment-timezone": "^0.5.12", "@types/mustache": "^0.8.31", "@types/node": ">=10.17.17 <10.20.0", @@ -450,7 +450,7 @@ "license-checker": "^16.0.0", "listr": "^0.14.1", "load-grunt-config": "^3.0.1", - "mocha": "^6.2.2", + "mocha": "^7.1.1", "mock-http-server": "1.3.0", "multistream": "^2.1.1", "murmurhash3js": "3.0.1", diff --git a/x-pack/package.json b/x-pack/package.json index 3ed01942518df..254240b461f66 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -78,7 +78,7 @@ "@types/mapbox-gl": "^0.54.1", "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", - "@types/mocha": "^5.2.7", + "@types/mocha": "^7.0.2", "@types/nock": "^10.0.3", "@types/node": ">=10.17.17 <10.20.0", "@types/node-fetch": "^2.5.0", @@ -145,7 +145,7 @@ "loader-utils": "^1.2.3", "madge": "3.4.4", "marge": "^1.0.1", - "mocha": "^6.2.2", + "mocha": "^7.1.1", "mocha-junit-reporter": "^1.23.1", "mochawesome": "^4.1.0", "mochawesome-merge": "^2.0.1", diff --git a/yarn.lock b/yarn.lock index 335dea4bf357d..19d193ccb3308 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5556,10 +5556,10 @@ dependencies: "@types/node" "*" -"@types/mocha@^5.2.7": - version "5.2.7" - resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.7.tgz#315d570ccb56c53452ff8638738df60726d5b6ea" - integrity sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ== +"@types/mocha@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-7.0.2.tgz#b17f16cf933597e10d6d78eae3251e692ce8b0ce" + integrity sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w== "@types/moment-timezone@^0.5.12": version "0.5.12" @@ -8639,6 +8639,13 @@ binaryextensions@2: resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.1.tgz#3209a51ca4a4ad541a3b8d3d6a6d5b83a2485935" integrity sha512-XBaoWE9RW8pPdPQNibZsW2zh8TW6gcarXp1FZPwT8Uop8ScSNldJEWf2k9l3HeTqdrEwsOsFcq74RiJECW34yA== +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + bit-twiddle@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bit-twiddle/-/bit-twiddle-1.0.2.tgz#0c6c1fabe2b23d17173d9a61b7b7093eb9e1769e" @@ -9803,6 +9810,21 @@ chokidar@3.2.1: optionalDependencies: fsevents "~2.1.0" +chokidar@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6" + integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.2.0" + optionalDependencies: + fsevents "~2.1.1" + chokidar@^2.0.0, chokidar@^2.1.2, chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" @@ -11806,7 +11828,7 @@ debug-fabulous@1.X: memoizee "0.4.X" object-assign "4.X" -debug@2.6.9, debug@^2.0.0, debug@^2.1.0, debug@^2.1.1, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: +debug@2.6.9, debug@^2.0.0, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -12200,11 +12222,6 @@ detect-indent@^4.0.0: dependencies: repeating "^2.0.0" -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detect-newline@2.X, detect-newline@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" @@ -14572,6 +14589,11 @@ file-type@^9.0.0: resolved "https://registry.yarnpkg.com/file-type/-/file-type-9.0.0.tgz#a68d5ad07f486414dfb2c8866f73161946714a18" integrity sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw== +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + filename-reserved-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz#abf73dfab735d045440abfea2d91f389ebbfa229" @@ -15193,17 +15215,17 @@ fs.realpath@^1.0.0: integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= fsevents@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.7.tgz#4851b664a3783e52003b3c66eb0eee1074933aa4" - integrity sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw== + version "1.2.12" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.12.tgz#db7e0d8ec3b0b45724fd4d83d43554a8f1f0de5c" + integrity sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q== dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" + bindings "^1.5.0" + nan "^2.12.1" -fsevents@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.0.tgz#ce1a5f9ac71c6d75278b0c5bd236d7dfece4cbaa" - integrity sha512-+iXhW3LuDQsno8dOIrCIT/CBjeBWuP7PXe8w9shnj9Lebny/Gx1ZjVBYwexLz36Ri2jKuXMNpV6CYNh8lHHgrQ== +fsevents@~2.1.0, fsevents@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" + integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== fstream@^1.0.0, fstream@^1.0.12: version "1.0.12" @@ -17227,7 +17249,7 @@ icalendar@0.7.1: resolved "https://registry.yarnpkg.com/icalendar/-/icalendar-0.7.1.tgz#d0d3486795f8f1c5cf4f8cafac081b4b4e7a32ae" integrity sha1-0NNIZ5X48cXPT4yvrAgbS056Mq4= -iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.22, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.22, iconv-lite@^0.4.24, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -17301,13 +17323,6 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -ignore-walk@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" - integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== - dependencies: - minimatch "^3.0.4" - ignore@^3.1.2, ignore@^3.3.5: version "3.3.10" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" @@ -21444,6 +21459,11 @@ minimist@^0.1.0: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.1.0.tgz#99df657a52574c21c9057497df742790b2b4c0de" integrity sha1-md9lelJXTCHJBXSX33QnkLK0wN4= +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + minimist@~0.0.1: version "0.0.10" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" @@ -21486,7 +21506,7 @@ minipass@^2.2.1: safe-buffer "^5.1.1" yallist "^3.0.0" -minipass@^2.2.4, minipass@^2.3.4: +minipass@^2.2.4: version "2.3.5" resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848" integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA== @@ -21516,13 +21536,6 @@ minizlib@^1.1.0, minizlib@^1.2.1: dependencies: minipass "^2.2.1" -minizlib@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.1.tgz#6734acc045a46e61d596a43bb9d9cd326e19cc42" - integrity sha512-TrfjCjk4jLhcJyGMYymBH6oTXcWjYbUAXTHDbtnWHjZC25h0cdajHuPE1zxb4DVmu8crfh+HwH/WMuyLG0nHBg== - dependencies: - minipass "^2.2.1" - mississippi@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" @@ -21574,6 +21587,13 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi dependencies: minimist "0.0.8" +mkdirp@0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.3.tgz#5a514b7179259287952881e94410ec5465659f8c" + integrity sha512-P+2gwrFqx8lhew375MQHHeTlY8AuOJSrGf0R5ddkEndUkmwpgUob/vQuBD1V22/Cw1/lJr4x+EjllSezBThzBg== + dependencies: + minimist "^1.2.5" + mkdirp@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.3.tgz#4cf2e30ad45959dddea53ad97d518b6c8205e1ea" @@ -21590,13 +21610,14 @@ mocha-junit-reporter@^1.23.1: strip-ansi "^4.0.0" xml "^1.0.0" -mocha@^6.2.2: - version "6.2.2" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-6.2.2.tgz#5d8987e28940caf8957a7d7664b910dc5b2fea20" - integrity sha512-FgDS9Re79yU1xz5d+C4rv1G7QagNGHZ+iXF81hO8zY35YZZcLEsJVfFolfsqKFWunATEvNzMK0r/CwWd/szO9A== +mocha@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.1.1.tgz#89fbb30d09429845b1bb893a830bf5771049a441" + integrity sha512-3qQsu3ijNS3GkWcccT5Zw0hf/rWvu1fTN9sPvEd81hlwsr30GX2GcDSSoBxo24IR8FelmrAydGC6/1J5QQP4WA== dependencies: ansi-colors "3.2.3" browser-stdout "1.3.1" + chokidar "3.3.0" debug "3.2.6" diff "3.5.0" escape-string-regexp "1.0.5" @@ -21605,18 +21626,18 @@ mocha@^6.2.2: growl "1.10.5" he "1.2.0" js-yaml "3.13.1" - log-symbols "2.2.0" + log-symbols "3.0.0" minimatch "3.0.4" - mkdirp "0.5.1" + mkdirp "0.5.3" ms "2.1.1" - node-environment-flags "1.0.5" + node-environment-flags "1.0.6" object.assign "4.1.0" strip-json-comments "2.0.1" supports-color "6.0.0" which "1.3.1" wide-align "1.1.3" - yargs "13.3.0" - yargs-parser "13.1.1" + yargs "13.3.2" + yargs-parser "13.1.2" yargs-unparser "1.6.0" mochawesome-merge@^2.0.1: @@ -21872,7 +21893,7 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nan@^2.13.2, nan@^2.9.2: +nan@^2.12.1, nan@^2.13.2: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== @@ -21954,15 +21975,6 @@ nearley@^2.7.10: randexp "0.4.6" semver "^5.4.1" -needle@^2.2.1: - version "2.2.4" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.4.tgz#51931bff82533b1928b7d1d69e01f1b00ffd2a4e" - integrity sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA== - dependencies: - debug "^2.1.2" - iconv-lite "^0.4.4" - sax "^1.2.4" - negotiator@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" @@ -22088,10 +22100,10 @@ node-ensure@^0.0.0: resolved "https://registry.yarnpkg.com/node-ensure/-/node-ensure-0.0.0.tgz#ecae764150de99861ec5c810fd5d096b183932a7" integrity sha1-7K52QVDemYYexcgQ/V0Jaxg5Mqc= -node-environment-flags@1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.5.tgz#fa930275f5bf5dae188d6192b24b4c8bbac3d76a" - integrity sha512-VNYPRfGfmZLx0Ye20jWzHUjyTW/c+6Wq+iLhDzUI4XmhrDd9l/FozXV3F2xOaXjvp0co0+v1YSR3CMP6g+VvLQ== +node-environment-flags@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088" + integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw== dependencies: object.getownpropertydescriptors "^2.0.3" semver "^5.7.0" @@ -22252,22 +22264,6 @@ node-notifier@^5.4.2: shellwords "^0.1.1" which "^1.3.0" -node-pre-gyp@^0.10.0: - version "0.10.3" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" - integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - node-releases@^1.1.25, node-releases@^1.1.46: version "1.1.47" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.47.tgz#c59ef739a1fd7ecbd9f0b7cf5b7871e8a8b591e4" @@ -22344,14 +22340,6 @@ nopt@^2.2.0: dependencies: abbrev "1" -nopt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" - integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= - dependencies: - abbrev "1" - osenv "^0.1.4" - normalize-package-data@^2.0.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.4.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" @@ -22420,11 +22408,6 @@ now-and-later@^2.0.0: dependencies: once "^1.3.2" -npm-bundled@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.3.tgz#7e71703d973af3370a9591bafe3a63aca0be2308" - integrity sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow== - npm-conf@^1.1.0, npm-conf@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/npm-conf/-/npm-conf-1.1.3.tgz#256cc47bd0e218c259c4e9550bf413bc2192aff9" @@ -22441,14 +22424,6 @@ npm-keyword@^5.0.0: got "^7.1.0" registry-url "^3.0.3" -npm-packlist@^1.1.6: - version "1.1.10" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.10.tgz#1039db9e985727e464df066f4cf0ab6ef85c398a" - integrity sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - npm-run-path@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-1.0.0.tgz#f5c32bf595fe81ae927daec52e82f8b000ac3c8f" @@ -22486,7 +22461,7 @@ npmconf@^2.1.3: semver "2 || 3 || 4" uid-number "0.0.5" -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2, npmlog@^4.1.2: +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -23063,14 +23038,6 @@ osenv@0, osenv@^0.1.0: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -osenv@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644" - integrity sha1-Qv5tWVPfBsgGS+bxdsPQWqqjRkQ= - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - output-file-sync@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/output-file-sync/-/output-file-sync-2.0.1.tgz#f53118282f5f553c2799541792b723a4c71430c0" @@ -25945,6 +25912,13 @@ readdirp@~3.1.3: dependencies: picomatch "^2.0.4" +readdirp@~3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839" + integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ== + dependencies: + picomatch "^2.0.4" + readline2@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" @@ -27310,7 +27284,7 @@ sass-lookup@^3.0.0: dependencies: commander "^2.16.0" -sax@>=0.6.0, sax@^1.2.1, sax@^1.2.4, sax@~1.2.4: +sax@>=0.6.0, sax@^1.2.1, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -29373,19 +29347,6 @@ tar@^2.0.0: fstream "^1.0.12" inherits "2" -tar@^4: - version "4.4.8" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d" - integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.3.4" - minizlib "^1.1.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.2" - tcomb-validation@^3.3.0: version "3.4.1" resolved "https://registry.yarnpkg.com/tcomb-validation/-/tcomb-validation-3.4.1.tgz#a7696ec176ce56a081d9e019f8b732a5a8894b65" @@ -32880,10 +32841,10 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yargs-parser@13.1.1, yargs-parser@^13.0.0, yargs-parser@^13.1.0, yargs-parser@^13.1.1: - version "13.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0" - integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ== +yargs-parser@13.1.2, yargs-parser@^13.0.0, yargs-parser@^13.1.0, yargs-parser@^13.1.1, yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -32904,9 +32865,9 @@ yargs-parser@^11.1.1: decamelize "^1.2.0" yargs-parser@^15.0.0: - version "15.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.0.tgz#cdd7a97490ec836195f59f3f4dbe5ea9e8f75f08" - integrity sha512-xLTUnCMc4JhxrPEPUYD5IBR1mWCK/aT6+RJ/K29JY2y1vD+FhtgKK0AXRWvI262q3QSffAQuTouFIKUuHX89wQ== + version "15.0.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.1.tgz#54786af40b820dcb2fb8025b11b4d659d76323b3" + integrity sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -32977,10 +32938,10 @@ yargs@13.2.4: y18n "^4.0.0" yargs-parser "^13.1.0" -yargs@13.3.0, yargs@^13.2.2, yargs@^13.3.0: - version "13.3.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" - integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== +yargs@13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== dependencies: cliui "^5.0.0" find-up "^3.0.0" @@ -32991,7 +32952,7 @@ yargs@13.3.0, yargs@^13.2.2, yargs@^13.3.0: string-width "^3.0.0" which-module "^2.0.0" y18n "^4.0.0" - yargs-parser "^13.1.1" + yargs-parser "^13.1.2" yargs@4.8.1: version "4.8.1" @@ -33031,6 +32992,22 @@ yargs@^11.0.0: y18n "^3.2.1" yargs-parser "^9.0.2" +yargs@^13.2.2, yargs@^13.3.0: + version "13.3.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" + integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.1" + yargs@^14.2.0: version "14.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.2.0.tgz#f116a9242c4ed8668790b40759b4906c276e76c3" From 8d6cd04a5ffff9c875a89b4e248f72ac519cb875 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Tue, 24 Mar 2020 15:09:43 +0000 Subject: [PATCH 03/12] [Alerting] removes unimplemented buttons from Alert Details page (#60934) (#61047) Removed the "Edit" and "View in Activity Log" buttons as they have not yet been implemented. --- .../components/alert_details.test.tsx | 60 +------------------ .../components/alert_details.tsx | 17 ------ 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index f025b0396f04d..d781e8b761845 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -8,16 +8,8 @@ import uuid from 'uuid'; import { shallow } from 'enzyme'; import { AlertDetails } from './alert_details'; import { Alert, ActionType } from '../../../../types'; -import { - EuiTitle, - EuiBadge, - EuiFlexItem, - EuiButtonEmpty, - EuiSwitch, - EuiBetaBadge, -} from '@elastic/eui'; +import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiBetaBadge } from '@elastic/eui'; import { times, random } from 'lodash'; -import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { ViewInApp } from './view_in_app'; @@ -218,31 +210,6 @@ describe('alert_details', () => { }); describe('links', () => { - it('links to the Edit flyout', () => { - const alert = mockAlert(); - - const alertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, - defaultActionGroupId: 'default', - }; - - expect( - shallow( - - ).containsMatchingElement( - - - - ) - ).toBeTruthy(); - }); - it('links to the app that created the alert', () => { const alert = mockAlert(); @@ -260,31 +227,6 @@ describe('alert_details', () => { ).containsMatchingElement() ).toBeTruthy(); }); - - it('links to the activity log', () => { - const alert = mockAlert(); - - const alertType = { - id: '.noop', - name: 'No Op', - actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, - defaultActionGroupId: 'default', - }; - - expect( - shallow( - - ).containsMatchingElement( - - - - ) - ).toBeTruthy(); - }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 49e818ebc7ee4..1f55e61e9ee0d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -17,7 +17,6 @@ import { EuiBadge, EuiPage, EuiPageContentBody, - EuiButtonEmpty, EuiSwitch, EuiCallOut, EuiSpacer, @@ -87,25 +86,9 @@ export const AlertDetails: React.FunctionComponent = ({ - - - - - - - - - - From 44c77eadf90182a3cc74a31da82e3ca280c1f42e Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 24 Mar 2020 16:26:17 +0100 Subject: [PATCH 04/12] =?UTF-8?q?[7.x]=20[APM]=20Re-revert=20"Collect=20te?= =?UTF-8?q?lemetry=20about=20data/API=20perfor=E2=80=A6=20(#61065)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "Revert "[APM] Collect telemetry about data/API performance (#51612)"" This reverts commit 6de7f2a62b2b078b703bbe6f18475909e1224f57. * Update transaction mock data to reflect the type --- x-pack/legacy/plugins/apm/index.ts | 19 +- x-pack/legacy/plugins/apm/mappings.json | 773 +++++++++++++++++- .../__test__/mockData.ts | 5 +- x-pack/legacy/plugins/apm/scripts/.gitignore | 1 + .../legacy/plugins/apm/scripts/package.json | 10 + .../apm/scripts/upload-telemetry-data.js | 21 + .../download-telemetry-template.ts | 26 + .../generate-sample-documents.ts | 124 +++ .../scripts/upload-telemetry-data/index.ts | 208 +++++ .../elasticsearch_fieldnames.test.ts.snap | 48 +- x-pack/plugins/apm/common/agent_name.ts | 44 +- .../apm/common/apm_saved_object_constants.ts | 10 +- .../common/elasticsearch_fieldnames.test.ts | 15 +- .../apm/common/elasticsearch_fieldnames.ts | 11 +- x-pack/plugins/apm/kibana.json | 5 +- x-pack/plugins/apm/server/index.ts | 6 +- .../lib/apm_telemetry/__test__/index.test.ts | 83 -- .../collect_data_telemetry/index.ts | 77 ++ .../collect_data_telemetry/tasks.ts | 725 ++++++++++++++++ .../apm/server/lib/apm_telemetry/index.ts | 155 +++- .../apm/server/lib/apm_telemetry/types.ts | 118 +++ .../server/lib/helpers/setup_request.test.ts | 13 + x-pack/plugins/apm/server/plugin.ts | 33 +- .../server/routes/create_api/index.test.ts | 1 + x-pack/plugins/apm/server/routes/services.ts | 16 - .../apm/typings/elasticsearch/aggregations.ts | 26 +- .../apm/typings/es_schemas/raw/error_raw.ts | 2 + .../typings/es_schemas/raw/fields/observer.ts | 10 + .../apm/typings/es_schemas/raw/span_raw.ts | 2 + .../typings/es_schemas/raw/transaction_raw.ts | 2 + 30 files changed, 2384 insertions(+), 205 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/scripts/.gitignore create mode 100644 x-pack/legacy/plugins/apm/scripts/package.json create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/types.ts create mode 100644 x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 6cfd18d0c1cba..502e910caae51 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -14,7 +14,13 @@ import mappings from './mappings.json'; export const apm: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ - require: ['kibana', 'elasticsearch', 'xpack_main', 'apm_oss'], + require: [ + 'kibana', + 'elasticsearch', + 'xpack_main', + 'apm_oss', + 'task_manager' + ], id: 'apm', configPrefix: 'xpack.apm', publicDir: resolve(__dirname, 'public'), @@ -76,7 +82,10 @@ export const apm: LegacyPluginInitializer = kibana => { serviceMapTraceIdBucketSize: Joi.number().default(65), serviceMapFingerprintGlobalBucketSize: Joi.number().default(1000), serviceMapTraceIdGlobalBucketSize: Joi.number().default(6), - serviceMapMaxTracesPerRequest: Joi.number().default(50) + serviceMapMaxTracesPerRequest: Joi.number().default(50), + + // telemetry + telemetryCollectionEnabled: Joi.boolean().default(true) }).default(); }, @@ -122,10 +131,12 @@ export const apm: LegacyPluginInitializer = kibana => { } } }); - const apmPlugin = server.newPlatform.setup.plugins .apm as APMPluginContract; - apmPlugin.registerLegacyAPI({ server }); + + apmPlugin.registerLegacyAPI({ + server + }); } }); }; diff --git a/x-pack/legacy/plugins/apm/mappings.json b/x-pack/legacy/plugins/apm/mappings.json index 61bc90da28756..ba4c7a89ceaa8 100644 --- a/x-pack/legacy/plugins/apm/mappings.json +++ b/x-pack/legacy/plugins/apm/mappings.json @@ -1,20 +1,659 @@ { - "apm-services-telemetry": { + "apm-telemetry": { "properties": { + "agents": { + "properties": { + "dotnet": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "go": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "java": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "js-base": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "type": "object" + } + } + } + } + }, + "nodejs": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "python": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "ruby": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "rum-js": { + "properties": { + "agent": { + "type": "object" + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "type": "object" + }, + "runtime": { + "type": "object" + } + } + } + } + } + } + }, + "counts": { + "properties": { + "agent_configuration": { + "properties": { + "all": { + "type": "long" + } + } + }, + "error": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "max_error_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "max_transaction_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "services": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "sourcemap": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "span": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "traces": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "user_agent": { + "properties": { + "original": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + }, + "transaction": { + "properties": { + "name": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + } + }, "has_any_services": { "type": "boolean" }, + "indices": { + "properties": { + "all": { + "properties": { + "total": { + "properties": { + "docs": { + "properties": { + "count": { + "type": "long" + } + } + }, + "store": { + "properties": { + "size_in_bytes": { + "type": "long" + } + } + } + } + } + } + }, + "shards": { + "properties": { + "total": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "ml": { + "properties": { + "all_jobs_count": { + "type": "long" + } + } + } + } + }, + "retainment": { + "properties": { + "error": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "span": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, "services_per_agent": { "properties": { - "python": { + "dotnet": { "type": "long", "null_value": 0 }, - "java": { + "go": { "type": "long", "null_value": 0 }, - "nodejs": { + "java": { "type": "long", "null_value": 0 }, @@ -22,11 +661,11 @@ "type": "long", "null_value": 0 }, - "rum-js": { + "nodejs": { "type": "long", "null_value": 0 }, - "dotnet": { + "python": { "type": "long", "null_value": 0 }, @@ -34,11 +673,131 @@ "type": "long", "null_value": 0 }, - "go": { + "rum-js": { "type": "long", "null_value": 0 } } + }, + "tasks": { + "properties": { + "agent_configuration": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "agents": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "groupings": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "indices_stats": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "processor_events": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "versions": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + }, + "version": { + "properties": { + "apm_server": { + "properties": { + "major": { + "type": "long" + }, + "minor": { + "type": "long" + }, + "patch": { + "type": "long" + } + } + } + } } } }, diff --git a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts index be8f379ce62ee..70be1a4744767 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts +++ b/x-pack/legacy/plugins/apm/public/components/shared/TransactionActionMenu/__test__/mockData.ts @@ -8,7 +8,10 @@ import { Location } from 'history'; const bareTransaction = { '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: '8.0.0', + version_major: 8 + }, agent: { name: 'java', version: '7.0.0' diff --git a/x-pack/legacy/plugins/apm/scripts/.gitignore b/x-pack/legacy/plugins/apm/scripts/.gitignore new file mode 100644 index 0000000000000..8ee01d321b721 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/.gitignore @@ -0,0 +1 @@ +yarn.lock diff --git a/x-pack/legacy/plugins/apm/scripts/package.json b/x-pack/legacy/plugins/apm/scripts/package.json new file mode 100644 index 0000000000000..9121449c53619 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/package.json @@ -0,0 +1,10 @@ +{ + "name": "apm-scripts", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@octokit/rest": "^16.35.0", + "console-stamp": "^0.2.9" + } +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js new file mode 100644 index 0000000000000..a99651c62dd7a --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js @@ -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. + */ + +// compile typescript on the fly +// eslint-disable-next-line import/no-extraneous-dependencies +require('@babel/register')({ + extensions: ['.ts'], + plugins: [ + '@babel/plugin-proposal-optional-chaining', + '@babel/plugin-proposal-nullish-coalescing-operator' + ], + presets: [ + '@babel/typescript', + ['@babel/preset-env', { targets: { node: 'current' } }] + ] +}); + +require('./upload-telemetry-data/index.ts'); diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts new file mode 100644 index 0000000000000..dfed9223ef708 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// @ts-ignore +import { Octokit } from '@octokit/rest'; + +export async function downloadTelemetryTemplate(octokit: Octokit) { + const file = await octokit.repos.getContents({ + owner: 'elastic', + repo: 'telemetry', + path: 'config/templates/xpack-phone-home.json', + // @ts-ignore + mediaType: { + format: 'application/vnd.github.VERSION.raw' + } + }); + + if (Array.isArray(file.data)) { + throw new Error('Expected single response, got array'); + } + + return JSON.parse(Buffer.from(file.data.content!, 'base64').toString()); +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts new file mode 100644 index 0000000000000..8d76063a7fdf6 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts @@ -0,0 +1,124 @@ +/* + * 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 { DeepPartial } from 'utility-types'; +import { + merge, + omit, + defaultsDeep, + range, + mapValues, + isPlainObject, + flatten +} from 'lodash'; +import uuid from 'uuid'; +import { + CollectTelemetryParams, + collectDataTelemetry + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; + +interface GenerateOptions { + days: number; + instances: number; + variation: { + min: number; + max: number; + }; +} + +const randomize = ( + value: unknown, + instanceVariation: number, + dailyGrowth: number +) => { + if (typeof value === 'boolean') { + return Math.random() > 0.5; + } + if (typeof value === 'number') { + return Math.round(instanceVariation * dailyGrowth * value); + } + return value; +}; + +const mapValuesDeep = ( + obj: Record, + iterator: (value: unknown, key: string, obj: Record) => unknown +): Record => + mapValues(obj, (val, key) => + isPlainObject(val) ? mapValuesDeep(val, iterator) : iterator(val, key!, obj) + ); + +export async function generateSampleDocuments( + options: DeepPartial & { + collectTelemetryParams: CollectTelemetryParams; + } +) { + const { collectTelemetryParams, ...preferredOptions } = options; + + const opts: GenerateOptions = defaultsDeep( + { + days: 100, + instances: 50, + variation: { + min: 0.1, + max: 4 + } + }, + preferredOptions + ); + + const sample = await collectDataTelemetry(collectTelemetryParams); + + console.log('Collected telemetry'); // eslint-disable-line no-console + console.log('\n' + JSON.stringify(sample, null, 2)); // eslint-disable-line no-console + + const dateOfScriptExecution = new Date(); + + return flatten( + range(0, opts.instances).map(instanceNo => { + const instanceId = uuid.v4(); + const defaults = { + cluster_uuid: instanceId, + stack_stats: { + kibana: { + versions: { + version: '8.0.0' + } + } + } + }; + + const instanceVariation = + Math.random() * (opts.variation.max - opts.variation.min) + + opts.variation.min; + + return range(0, opts.days).map(dayNo => { + const dailyGrowth = Math.pow(1.005, opts.days - 1 - dayNo); + + const timestamp = Date.UTC( + dateOfScriptExecution.getFullYear(), + dateOfScriptExecution.getMonth(), + -dayNo + ); + + const generated = mapValuesDeep(omit(sample, 'versions'), value => + randomize(value, instanceVariation, dailyGrowth) + ); + + return merge({}, defaults, { + timestamp, + stack_stats: { + kibana: { + plugins: { + apm: merge({}, sample, generated) + } + } + } + }); + }); + }) + ); +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts new file mode 100644 index 0000000000000..bdc57eac412fc --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.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. + */ + +// This script downloads the telemetry mapping, runs the APM telemetry tasks, +// generates a bunch of randomized data based on the downloaded sample, +// and uploads it to a cluster of your choosing in the same format as it is +// stored in the telemetry cluster. Its purpose is twofold: +// - Easier testing of the telemetry tasks +// - Validate whether we can run the queries we want to on the telemetry data + +import fs from 'fs'; +import path from 'path'; +// @ts-ignore +import { Octokit } from '@octokit/rest'; +import { merge, chunk, flatten, pick, identity } from 'lodash'; +import axios from 'axios'; +import yaml from 'js-yaml'; +import { Client } from 'elasticsearch'; +import { argv } from 'yargs'; +import { promisify } from 'util'; +import { Logger } from 'kibana/server'; +// @ts-ignore +import consoleStamp from 'console-stamp'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { CollectTelemetryParams } from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; +import { downloadTelemetryTemplate } from './download-telemetry-template'; +import mapping from '../../mappings.json'; +import { generateSampleDocuments } from './generate-sample-documents'; + +consoleStamp(console, '[HH:MM:ss.l]'); + +const githubToken = process.env.GITHUB_TOKEN; + +if (!githubToken) { + throw new Error('GITHUB_TOKEN was not provided.'); +} + +const kibanaConfigDir = path.join(__filename, '../../../../../../../config'); +const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); +const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); + +const xpackTelemetryIndexName = 'xpack-phone-home'; + +const loadedKibanaConfig = (yaml.safeLoad( + fs.readFileSync( + fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, + 'utf8' + ) +) || {}) as {}; + +const cliEsCredentials = pick( + { + 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, + 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, + 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST + }, + identity +) as { + 'elasticsearch.username': string; + 'elasticsearch.password': string; + 'elasticsearch.hosts': string; +}; + +const config = { + 'apm_oss.transactionIndices': 'apm-*', + 'apm_oss.metricsIndices': 'apm-*', + 'apm_oss.errorIndices': 'apm-*', + 'apm_oss.spanIndices': 'apm-*', + 'apm_oss.onboardingIndices': 'apm-*', + 'apm_oss.sourcemapIndices': 'apm-*', + 'elasticsearch.hosts': 'http://localhost:9200', + ...loadedKibanaConfig, + ...cliEsCredentials +}; + +async function uploadData() { + const octokit = new Octokit({ + auth: githubToken + }); + + const telemetryTemplate = await downloadTelemetryTemplate(octokit); + + const kibanaMapping = mapping['apm-telemetry']; + + const httpAuth = + config['elasticsearch.username'] && config['elasticsearch.password'] + ? { + username: config['elasticsearch.username'], + password: config['elasticsearch.password'] + } + : null; + + const client = new Client({ + host: config['elasticsearch.hosts'], + ...(httpAuth + ? { + httpAuth: `${httpAuth.username}:${httpAuth.password}` + } + : {}) + }); + + if (argv.clear) { + try { + await promisify(client.indices.delete.bind(client))({ + index: xpackTelemetryIndexName + }); + } catch (err) { + // 404 = index not found, totally okay + if (err.status !== 404) { + throw err; + } + } + } + + const axiosInstance = axios.create({ + baseURL: config['elasticsearch.hosts'], + ...(httpAuth ? { auth: httpAuth } : {}) + }); + + const newTemplate = merge(telemetryTemplate, { + settings: { + index: { mapping: { total_fields: { limit: 10000 } } } + } + }); + + // override apm mapping instead of merging + newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; + + await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); + + const sampleDocuments = await generateSampleDocuments({ + collectTelemetryParams: { + logger: (console as unknown) as Logger, + indices: { + ...config, + apmCustomLinkIndex: '.apm-custom-links', + apmAgentConfigurationIndex: '.apm-agent-configuration' + }, + search: body => { + return promisify(client.search.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + indicesStats: body => { + return promisify(client.indices.stats.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + transportRequest: (params => { + return axiosInstance[params.method](params.path); + }) as CollectTelemetryParams['transportRequest'] + } + }); + + const chunks = chunk(sampleDocuments, 250); + + await chunks.reduce>((prev, documents) => { + return prev.then(async () => { + const body = flatten( + documents.map(doc => [{ index: { _index: 'xpack-phone-home' } }, doc]) + ); + + return promisify(client.bulk.bind(client))({ + body, + refresh: true + }).then((response: any) => { + if (response.errors) { + const firstError = response.items.filter( + (item: any) => item.index.status >= 400 + )[0].index.error; + throw new Error(`Failed to upload documents: ${firstError.reason} `); + } + }); + }); + }, Promise.resolve()); +} + +uploadData() + .catch(e => { + if ('response' in e) { + if (typeof e.response === 'string') { + // eslint-disable-next-line no-console + console.log(e.response); + } else { + // eslint-disable-next-line no-console + console.log( + JSON.stringify( + e.response, + ['status', 'statusText', 'headers', 'data'], + 2 + ) + ); + } + } else { + // eslint-disable-next-line no-console + console.log(e); + } + process.exit(1); + }) + .then(() => { + // eslint-disable-next-line no-console + console.log('Finished uploading generated telemetry data'); + }); diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 897d4e979fce3..5de82a9ee8788 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -2,6 +2,8 @@ exports[`Error AGENT_NAME 1`] = `"java"`; +exports[`Error AGENT_VERSION 1`] = `"agent version"`; + exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; @@ -56,7 +58,7 @@ exports[`Error METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Error OBSERVER_LISTENING 1`] = `undefined`; -exports[`Error OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Error OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Error PARENT_ID 1`] = `"parentId"`; @@ -68,10 +70,20 @@ exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Error SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Error SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; + +exports[`Error SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; + exports[`Error SERVICE_NAME 1`] = `"service name"`; exports[`Error SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Error SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Error SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Error SERVICE_VERSION 1`] = `undefined`; exports[`Error SPAN_ACTION 1`] = `undefined`; @@ -112,10 +124,14 @@ exports[`Error URL_FULL 1`] = `undefined`; exports[`Error USER_AGENT_NAME 1`] = `undefined`; +exports[`Error USER_AGENT_ORIGINAL 1`] = `undefined`; + exports[`Error USER_ID 1`] = `undefined`; exports[`Span AGENT_NAME 1`] = `"java"`; +exports[`Span AGENT_VERSION 1`] = `"agent version"`; + exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; @@ -170,7 +186,7 @@ exports[`Span METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Span OBSERVER_LISTENING 1`] = `undefined`; -exports[`Span OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Span OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Span PARENT_ID 1`] = `"parentId"`; @@ -182,10 +198,20 @@ exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Span SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Span SERVICE_LANGUAGE_NAME 1`] = `undefined`; + +exports[`Span SERVICE_LANGUAGE_VERSION 1`] = `undefined`; + exports[`Span SERVICE_NAME 1`] = `"service name"`; exports[`Span SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Span SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Span SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Span SERVICE_VERSION 1`] = `undefined`; exports[`Span SPAN_ACTION 1`] = `"my action"`; @@ -226,10 +252,14 @@ exports[`Span URL_FULL 1`] = `undefined`; exports[`Span USER_AGENT_NAME 1`] = `undefined`; +exports[`Span USER_AGENT_ORIGINAL 1`] = `undefined`; + exports[`Span USER_ID 1`] = `undefined`; exports[`Transaction AGENT_NAME 1`] = `"java"`; +exports[`Transaction AGENT_VERSION 1`] = `"agent version"`; + exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; @@ -284,7 +314,7 @@ exports[`Transaction METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Transaction OBSERVER_LISTENING 1`] = `undefined`; -exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Transaction PARENT_ID 1`] = `"parentId"`; @@ -296,10 +326,20 @@ exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Transaction SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Transaction SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; + +exports[`Transaction SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; + exports[`Transaction SERVICE_NAME 1`] = `"service name"`; exports[`Transaction SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Transaction SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Transaction SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Transaction SERVICE_VERSION 1`] = `undefined`; exports[`Transaction SPAN_ACTION 1`] = `undefined`; @@ -340,4 +380,6 @@ exports[`Transaction URL_FULL 1`] = `"http://www.elastic.co"`; exports[`Transaction USER_AGENT_NAME 1`] = `"Other"`; +exports[`Transaction USER_AGENT_ORIGINAL 1`] = `"test original"`; + exports[`Transaction USER_ID 1`] = `"1337"`; diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index bb68eb88b8e18..085828b729ea5 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -4,36 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AgentName } from '../typings/es_schemas/ui/fields/agent'; + /* * Agent names can be any string. This list only defines the official agents * that we might want to target specifically eg. linking to their documentation * & telemetry reporting. Support additional agent types by appending * definitions in mappings.json (for telemetry), the AgentName type, and the - * agentNames object. + * AGENT_NAMES array. */ -import { AgentName } from '../typings/es_schemas/ui/fields/agent'; -const agentNames: { [agentName in AgentName]: agentName } = { - python: 'python', - java: 'java', - nodejs: 'nodejs', - 'js-base': 'js-base', - 'rum-js': 'rum-js', - dotnet: 'dotnet', - ruby: 'ruby', - go: 'go' -}; +export const AGENT_NAMES: AgentName[] = [ + 'java', + 'js-base', + 'rum-js', + 'dotnet', + 'go', + 'java', + 'nodejs', + 'python', + 'ruby' +]; -export function isAgentName(agentName: string): boolean { - return Object.values(agentNames).includes(agentName as AgentName); +export function isAgentName(agentName: string): agentName is AgentName { + return AGENT_NAMES.includes(agentName as AgentName); } -export function isRumAgentName(agentName: string | undefined) { - return ( - agentName === agentNames['js-base'] || agentName === agentNames['rum-js'] - ); +export function isRumAgentName( + agentName: string | undefined +): agentName is 'js-base' | 'rum-js' { + return agentName === 'js-base' || agentName === 'rum-js'; } -export function isJavaAgentName(agentName: string | undefined) { - return agentName === agentNames.java; +export function isJavaAgentName( + agentName: string | undefined +): agentName is 'java' { + return agentName === 'java'; } diff --git a/x-pack/plugins/apm/common/apm_saved_object_constants.ts b/x-pack/plugins/apm/common/apm_saved_object_constants.ts index ac43b700117c6..0529d90fe940a 100644 --- a/x-pack/plugins/apm/common/apm_saved_object_constants.ts +++ b/x-pack/plugins/apm/common/apm_saved_object_constants.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -// APM Services telemetry -export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE = - 'apm-services-telemetry'; -export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID = 'apm-services-telemetry'; +// the types have to match the names of the saved object mappings +// in /x-pack/legacy/plugins/apm/mappings.json // APM indices export const APM_INDICES_SAVED_OBJECT_TYPE = 'apm-indices'; export const APM_INDICES_SAVED_OBJECT_ID = 'apm-indices'; + +// APM telemetry +export const APM_TELEMETRY_SAVED_OBJECT_TYPE = 'apm-telemetry'; +export const APM_TELEMETRY_SAVED_OBJECT_ID = 'apm-telemetry'; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts index 8516344794730..085662eec1efb 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts @@ -14,7 +14,10 @@ describe('Transaction', () => { const transaction: Transaction = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' @@ -62,7 +65,10 @@ describe('Span', () => { const span: Span = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' @@ -106,7 +112,10 @@ describe('Span', () => { describe('Error', () => { const errorDoc: APMError = { '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 822201baddd88..bc1b346f50da7 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -4,15 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -export const AGENT_NAME = 'agent.name'; export const SERVICE_NAME = 'service.name'; export const SERVICE_ENVIRONMENT = 'service.environment'; export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; +export const SERVICE_FRAMEWORK_VERSION = 'service.framework.version'; +export const SERVICE_LANGUAGE_NAME = 'service.language.name'; +export const SERVICE_LANGUAGE_VERSION = 'service.language.version'; +export const SERVICE_RUNTIME_NAME = 'service.runtime.name'; +export const SERVICE_RUNTIME_VERSION = 'service.runtime.version'; export const SERVICE_NODE_NAME = 'service.node.name'; export const SERVICE_VERSION = 'service.version'; + +export const AGENT_NAME = 'agent.name'; +export const AGENT_VERSION = 'agent.version'; + export const URL_FULL = 'url.full'; export const HTTP_REQUEST_METHOD = 'http.request.method'; export const USER_ID = 'user.id'; +export const USER_AGENT_ORIGINAL = 'user_agent.original'; export const USER_AGENT_NAME = 'user_agent.name'; export const DESTINATION_ADDRESS = 'destination.address'; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 931fd92e1ecc3..7ffdb676c740f 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -3,7 +3,10 @@ "server": true, "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "apm"], + "configPath": [ + "xpack", + "apm" + ], "ui": false, "requiredPlugins": ["apm_oss", "data", "home", "licensing"], "optionalPlugins": ["cloud", "usageCollection", "taskManager","actions", "alerting"] diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 8afdb9e99c1a3..77655568a7e9c 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -29,7 +29,8 @@ export const config = { enabled: schema.boolean({ defaultValue: true }), transactionGroupBucketSize: schema.number({ defaultValue: 100 }), maxTraceItems: schema.number({ defaultValue: 1000 }) - }) + }), + telemetryCollectionEnabled: schema.boolean({ defaultValue: true }) }) }; @@ -62,7 +63,8 @@ export function mergeConfigs( 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, - 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern + 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern, + 'xpack.apm.telemetryCollectionEnabled': apmConfig.telemetryCollectionEnabled }; } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts deleted file mode 100644 index c45c74a791aee..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts +++ /dev/null @@ -1,83 +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 { SavedObjectAttributes } from '../../../../../../../src/core/server'; -import { createApmTelementry, storeApmServicesTelemetry } from '../index'; -import { - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID -} from '../../../../common/apm_saved_object_constants'; - -describe('apm_telemetry', () => { - describe('createApmTelementry', () => { - it('should create a ApmTelemetry object with boolean flag and frequency map of the given list of AgentNames', () => { - const apmTelemetry = createApmTelementry([ - 'go', - 'nodejs', - 'go', - 'js-base' - ]); - expect(apmTelemetry.has_any_services).toBe(true); - expect(apmTelemetry.services_per_agent).toMatchObject({ - go: 2, - nodejs: 1, - 'js-base': 1 - }); - }); - it('should ignore undefined or unknown AgentName values', () => { - const apmTelemetry = createApmTelementry([ - 'go', - 'nodejs', - 'go', - 'js-base', - 'example-platform' as any, - undefined as any - ]); - expect(apmTelemetry.services_per_agent).toMatchObject({ - go: 2, - nodejs: 1, - 'js-base': 1 - }); - }); - }); - - describe('storeApmServicesTelemetry', () => { - let apmTelemetry: SavedObjectAttributes; - let savedObjectsClient: any; - - beforeEach(() => { - apmTelemetry = { - has_any_services: true, - services_per_agent: { - go: 2, - nodejs: 1, - 'js-base': 1 - } - }; - savedObjectsClient = { create: jest.fn() }; - }); - - it('should call savedObjectsClient create with the given ApmTelemetry object', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][1]).toBe(apmTelemetry); - }); - - it('should call savedObjectsClient create with the apm-telemetry document type and ID', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][0]).toBe( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE - ); - expect(savedObjectsClient.create.mock.calls[0][2].id).toBe( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID - ); - }); - - it('should call savedObjectsClient create with overwrite: true', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][2].overwrite).toBe(true); - }); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts new file mode 100644 index 0000000000000..729ccb73d73f3 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.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 { merge } from 'lodash'; +import { Logger, CallAPIOptions } from 'kibana/server'; +import { IndicesStatsParams, Client } from 'elasticsearch'; +import { + ESSearchRequest, + ESSearchResponse +} from '../../../../typings/elasticsearch'; +import { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; +import { tasks } from './tasks'; +import { APMDataTelemetry } from '../types'; + +type TelemetryTaskExecutor = (params: { + indices: ApmIndicesConfig; + search( + params: TSearchRequest + ): Promise>; + indicesStats( + params: IndicesStatsParams, + options?: CallAPIOptions + ): ReturnType; + transportRequest: (params: { + path: string; + method: 'get'; + }) => Promise; +}) => Promise; + +export interface TelemetryTask { + name: string; + executor: TelemetryTaskExecutor; +} + +export type CollectTelemetryParams = Parameters[0] & { + logger: Logger; +}; + +export function collectDataTelemetry({ + search, + indices, + logger, + indicesStats, + transportRequest +}: CollectTelemetryParams) { + return tasks.reduce((prev, task) => { + return prev.then(async data => { + logger.debug(`Executing APM telemetry task ${task.name}`); + try { + const time = process.hrtime(); + const next = await task.executor({ + search, + indices, + indicesStats, + transportRequest + }); + const took = process.hrtime(time); + + return merge({}, data, next, { + tasks: { + [task.name]: { + took: { + ms: Math.round(took[0] * 1000 + took[1] / 1e6) + } + } + } + }); + } catch (err) { + logger.warn(`Failed executing APM telemetry task ${task.name}`); + logger.warn(err); + return data; + } + }); + }, Promise.resolve({} as APMDataTelemetry)); +} diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts new file mode 100644 index 0000000000000..415076b6ae116 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -0,0 +1,725 @@ +/* + * 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 { flatten, merge, sortBy, sum } from 'lodash'; +import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; +import { AGENT_NAMES } from '../../../../common/agent_name'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + AGENT_NAME, + AGENT_VERSION, + ERROR_GROUP_ID, + TRANSACTION_NAME, + PARENT_ID, + SERVICE_FRAMEWORK_NAME, + SERVICE_FRAMEWORK_VERSION, + SERVICE_LANGUAGE_NAME, + SERVICE_LANGUAGE_VERSION, + SERVICE_RUNTIME_NAME, + SERVICE_RUNTIME_VERSION, + USER_AGENT_ORIGINAL +} from '../../../../common/elasticsearch_fieldnames'; +import { Span } from '../../../../typings/es_schemas/ui/span'; +import { APMError } from '../../../../typings/es_schemas/ui/apm_error'; +import { TelemetryTask } from '.'; +import { APMTelemetry } from '../types'; + +const TIME_RANGES = ['1d', 'all'] as const; +type TimeRange = typeof TIME_RANGES[number]; + +export const tasks: TelemetryTask[] = [ + { + name: 'processor_events', + executor: async ({ indices, search }) => { + const indicesByProcessorEvent = { + error: indices['apm_oss.errorIndices'], + metric: indices['apm_oss.metricsIndices'], + span: indices['apm_oss.spanIndices'], + transaction: indices['apm_oss.transactionIndices'], + onboarding: indices['apm_oss.onboardingIndices'], + sourcemap: indices['apm_oss.sourcemapIndices'] + }; + + type ProcessorEvent = keyof typeof indicesByProcessorEvent; + + const jobs: Array<{ + processorEvent: ProcessorEvent; + timeRange: TimeRange; + }> = flatten( + (Object.keys( + indicesByProcessorEvent + ) as ProcessorEvent[]).map(processorEvent => + TIME_RANGES.map(timeRange => ({ processorEvent, timeRange })) + ) + ); + + const allData = await jobs.reduce((prevJob, current) => { + return prevJob.then(async data => { + const { processorEvent, timeRange } = current; + + const response = await search({ + index: indicesByProcessorEvent[processorEvent], + body: { + size: 1, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: processorEvent } }, + ...(timeRange !== 'all' + ? [ + { + range: { + '@timestamp': { + gte: `now-${timeRange}` + } + } + } + ] + : []) + ] + } + }, + sort: { + '@timestamp': 'asc' + }, + _source: ['@timestamp'], + track_total_hits: true + } + }); + + const event = response.hits.hits[0]?._source as { + '@timestamp': number; + }; + + return merge({}, data, { + counts: { + [processorEvent]: { + [timeRange]: response.hits.total.value + } + }, + ...(timeRange === 'all' && event + ? { + retainment: { + [processorEvent]: { + ms: + new Date().getTime() - + new Date(event['@timestamp']).getTime() + } + } + } + : {}) + }); + }); + }, Promise.resolve({} as Record> }>)); + + return allData; + } + }, + { + name: 'agent_configuration', + executor: async ({ indices, search }) => { + const agentConfigurationCount = ( + await search({ + index: indices.apmAgentConfigurationIndex, + body: { + size: 0, + track_total_hits: true + } + }) + ).hits.total.value; + + return { + counts: { + agent_configuration: { + all: agentConfigurationCount + } + } + }; + } + }, + { + name: 'services', + executor: async ({ indices, search }) => { + const servicesPerAgent = await AGENT_NAMES.reduce( + (prevJob, agentName) => { + return prevJob.then(async data => { + const response = await search({ + index: [ + indices['apm_oss.errorIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { + term: { + [AGENT_NAME]: agentName + } + }, + { + range: { + '@timestamp': { + gte: 'now-1d' + } + } + } + ] + } + }, + aggs: { + services: { + cardinality: { + field: SERVICE_NAME + } + } + } + } + }); + + return { + ...data, + [agentName]: response.aggregations?.services.value || 0 + }; + }); + }, + Promise.resolve({} as Record) + ); + + return { + has_any_services: sum(Object.values(servicesPerAgent)) > 0, + services_per_agent: servicesPerAgent + }; + } + }, + { + name: 'versions', + executor: async ({ search, indices }) => { + const response = await search({ + index: [ + indices['apm_oss.transactionIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.errorIndices'] + ], + terminateAfter: 1, + body: { + query: { + exists: { + field: 'observer.version' + } + }, + size: 1, + sort: { + '@timestamp': 'desc' + } + } + }); + + const hit = response.hits.hits[0]?._source as Pick< + Transaction | Span | APMError, + 'observer' + >; + + if (!hit || !hit.observer?.version) { + return {}; + } + + const [major, minor, patch] = hit.observer.version + .split('.') + .map(part => Number(part)); + + return { + versions: { + apm_server: { + major, + minor, + patch + } + } + }; + } + }, + { + name: 'groupings', + executor: async ({ search, indices }) => { + const range1d = { range: { '@timestamp': { gte: 'now-1d' } } }; + const errorGroupsCount = ( + await search({ + index: indices['apm_oss.errorIndices'], + body: { + size: 0, + query: { + bool: { + filter: [{ term: { [PROCESSOR_EVENT]: 'error' } }, range1d] + } + }, + aggs: { + top_service: { + terms: { + field: SERVICE_NAME, + order: { + error_groups: 'desc' + }, + size: 1 + }, + aggs: { + error_groups: { + cardinality: { + field: ERROR_GROUP_ID + } + } + } + } + } + } + }) + ).aggregations?.top_service.buckets[0]?.error_groups.value; + + const transactionGroupsCount = ( + await search({ + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + range1d + ] + } + }, + aggs: { + top_service: { + terms: { + field: SERVICE_NAME, + order: { + transaction_groups: 'desc' + }, + size: 1 + }, + aggs: { + transaction_groups: { + cardinality: { + field: TRANSACTION_NAME + } + } + } + } + } + } + }) + ).aggregations?.top_service.buckets[0]?.transaction_groups.value; + + const tracesPerDayCount = ( + await search({ + index: indices['apm_oss.transactionIndices'], + body: { + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + range1d + ], + must_not: { + exists: { field: PARENT_ID } + } + } + }, + track_total_hits: true, + size: 0 + } + }) + ).hits.total.value; + + const servicesCount = ( + await search({ + index: [ + indices['apm_oss.transactionIndices'], + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [range1d] + } + }, + aggs: { + service_name: { + cardinality: { + field: SERVICE_NAME + } + } + } + } + }) + ).aggregations?.service_name.value; + + return { + counts: { + max_error_groups_per_service: { + '1d': errorGroupsCount || 0 + }, + max_transaction_groups_per_service: { + '1d': transactionGroupsCount || 0 + }, + traces: { + '1d': tracesPerDayCount || 0 + }, + services: { + '1d': servicesCount || 0 + } + } + }; + } + }, + { + name: 'integrations', + executor: async ({ transportRequest }) => { + const apmJobs = ['*-high_mean_response_time']; + + const response = (await transportRequest({ + method: 'get', + path: `/_ml/anomaly_detectors/${apmJobs.join(',')}` + })) as { data?: { count: number } }; + + return { + integrations: { + ml: { + all_jobs_count: response.data?.count ?? 0 + } + } + }; + } + }, + { + name: 'agents', + executor: async ({ search, indices }) => { + const size = 3; + + const agentData = await AGENT_NAMES.reduce(async (prevJob, agentName) => { + const data = await prevJob; + + const response = await search({ + index: [ + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [AGENT_NAME]: agentName } }, + { range: { '@timestamp': { gte: 'now-1d' } } } + ] + } + }, + sort: { + '@timestamp': 'desc' + }, + aggs: { + [AGENT_VERSION]: { + terms: { + field: AGENT_VERSION, + size + } + }, + [SERVICE_FRAMEWORK_NAME]: { + terms: { + field: SERVICE_FRAMEWORK_NAME, + size + }, + aggs: { + [SERVICE_FRAMEWORK_VERSION]: { + terms: { + field: SERVICE_FRAMEWORK_VERSION, + size + } + } + } + }, + [SERVICE_FRAMEWORK_VERSION]: { + terms: { + field: SERVICE_FRAMEWORK_VERSION, + size + } + }, + [SERVICE_LANGUAGE_NAME]: { + terms: { + field: SERVICE_LANGUAGE_NAME, + size + }, + aggs: { + [SERVICE_LANGUAGE_VERSION]: { + terms: { + field: SERVICE_LANGUAGE_VERSION, + size + } + } + } + }, + [SERVICE_LANGUAGE_VERSION]: { + terms: { + field: SERVICE_LANGUAGE_VERSION, + size + } + }, + [SERVICE_RUNTIME_NAME]: { + terms: { + field: SERVICE_RUNTIME_NAME, + size + }, + aggs: { + [SERVICE_RUNTIME_VERSION]: { + terms: { + field: SERVICE_RUNTIME_VERSION, + size + } + } + } + }, + [SERVICE_RUNTIME_VERSION]: { + terms: { + field: SERVICE_RUNTIME_VERSION, + size + } + } + } + } + }); + + const { aggregations } = response; + + if (!aggregations) { + return data; + } + + const toComposite = ( + outerKey: string | number, + innerKey: string | number + ) => `${outerKey}/${innerKey}`; + + return { + ...data, + [agentName]: { + agent: { + version: aggregations[AGENT_VERSION].buckets.map( + bucket => bucket.key as string + ) + }, + service: { + framework: { + name: aggregations[SERVICE_FRAMEWORK_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_FRAMEWORK_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_FRAMEWORK_NAME].buckets.map(bucket => + bucket[SERVICE_FRAMEWORK_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + }, + language: { + name: aggregations[SERVICE_LANGUAGE_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_LANGUAGE_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_LANGUAGE_NAME].buckets.map(bucket => + bucket[SERVICE_LANGUAGE_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + }, + runtime: { + name: aggregations[SERVICE_RUNTIME_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_RUNTIME_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_RUNTIME_NAME].buckets.map(bucket => + bucket[SERVICE_RUNTIME_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + } + } + } + }; + }, Promise.resolve({} as APMTelemetry['agents'])); + + return { + agents: agentData + }; + } + }, + { + name: 'indices_stats', + executor: async ({ indicesStats, indices }) => { + const response = await indicesStats({ + index: [ + indices.apmAgentConfigurationIndex, + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.onboardingIndices'], + indices['apm_oss.sourcemapIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.transactionIndices'] + ] + }); + + return { + indices: { + shards: { + total: response._shards.total + }, + all: { + total: { + docs: { + count: response._all.total.docs.count + }, + store: { + size_in_bytes: response._all.total.store.size_in_bytes + } + } + } + } + }; + } + }, + { + name: 'cardinality', + executor: async ({ search }) => { + const allAgentsCardinalityResponse = await search({ + body: { + size: 0, + query: { + bool: { + filter: [{ range: { '@timestamp': { gte: 'now-1d' } } }] + } + }, + aggs: { + [TRANSACTION_NAME]: { + cardinality: { + field: TRANSACTION_NAME + } + }, + [USER_AGENT_ORIGINAL]: { + cardinality: { + field: USER_AGENT_ORIGINAL + } + } + } + } + }); + + const rumAgentCardinalityResponse = await search({ + body: { + size: 0, + query: { + bool: { + filter: [ + { range: { '@timestamp': { gte: 'now-1d' } } }, + { terms: { [AGENT_NAME]: ['rum-js', 'js-base'] } } + ] + } + }, + aggs: { + [TRANSACTION_NAME]: { + cardinality: { + field: TRANSACTION_NAME + } + }, + [USER_AGENT_ORIGINAL]: { + cardinality: { + field: USER_AGENT_ORIGINAL + } + } + } + } + }); + + return { + cardinality: { + transaction: { + name: { + all_agents: { + '1d': + allAgentsCardinalityResponse.aggregations?.[TRANSACTION_NAME] + .value + }, + rum: { + '1d': + rumAgentCardinalityResponse.aggregations?.[TRANSACTION_NAME] + .value + } + } + }, + user_agent: { + original: { + all_agents: { + '1d': + allAgentsCardinalityResponse.aggregations?.[ + USER_AGENT_ORIGINAL + ].value + }, + rum: { + '1d': + rumAgentCardinalityResponse.aggregations?.[ + USER_AGENT_ORIGINAL + ].value + } + } + } + } + }; + } + } +]; diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index a2b0494730826..c80057a2894dc 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -3,60 +3,127 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { countBy } from 'lodash'; -import { SavedObjectAttributes } from '../../../../../../src/core/server'; -import { isAgentName } from '../../../common/agent_name'; +import { CoreSetup, Logger } from 'src/core/server'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { + TaskManagerStartContract, + TaskManagerSetupContract +} from '../../../../task_manager/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID + APM_TELEMETRY_SAVED_OBJECT_ID, + APM_TELEMETRY_SAVED_OBJECT_TYPE } from '../../../common/apm_saved_object_constants'; -import { UsageCollectionSetup } from '../../../../../../src/plugins/usage_collection/server'; -import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; - -export function createApmTelementry( - agentNames: string[] = [] -): SavedObjectAttributes { - const validAgentNames = agentNames.filter(isAgentName); - return { - has_any_services: validAgentNames.length > 0, - services_per_agent: countBy(validAgentNames) +import { + collectDataTelemetry, + CollectTelemetryParams +} from './collect_data_telemetry'; +import { APMConfig } from '../..'; +import { getInternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; + +const APM_TELEMETRY_TASK_NAME = 'apm-telemetry-task'; + +export async function createApmTelemetry({ + core, + config$, + usageCollector, + taskManager, + logger +}: { + core: CoreSetup; + config$: Observable; + usageCollector: UsageCollectionSetup; + taskManager: TaskManagerSetupContract; + logger: Logger; +}) { + const savedObjectsClient = await getInternalSavedObjectsClient(core); + + const collectAndStore = async () => { + const config = await config$.pipe(take(1)).toPromise(); + const esClient = core.elasticsearch.dataClient; + + const indices = await getApmIndices({ + config, + savedObjectsClient + }); + + const search = esClient.callAsInternalUser.bind( + esClient, + 'search' + ) as CollectTelemetryParams['search']; + + const indicesStats = esClient.callAsInternalUser.bind( + esClient, + 'indices.stats' + ) as CollectTelemetryParams['indicesStats']; + + const transportRequest = esClient.callAsInternalUser.bind( + esClient, + 'transport.request' + ) as CollectTelemetryParams['transportRequest']; + + const dataTelemetry = await collectDataTelemetry({ + search, + indices, + logger, + indicesStats, + transportRequest + }); + + await savedObjectsClient.create( + APM_TELEMETRY_SAVED_OBJECT_TYPE, + dataTelemetry, + { id: APM_TELEMETRY_SAVED_OBJECT_TYPE, overwrite: true } + ); }; -} -export async function storeApmServicesTelemetry( - savedObjectsClient: InternalSavedObjectsClient, - apmTelemetry: SavedObjectAttributes -) { - return savedObjectsClient.create( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - apmTelemetry, - { - id: APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID, - overwrite: true + taskManager.registerTaskDefinitions({ + [APM_TELEMETRY_TASK_NAME]: { + title: 'Collect APM telemetry', + type: APM_TELEMETRY_TASK_NAME, + createTaskRunner: () => { + return { + run: async () => { + await collectAndStore(); + } + }; + } } - ); -} + }); -export function makeApmUsageCollector( - usageCollector: UsageCollectionSetup, - savedObjectsRepository: InternalSavedObjectsClient -) { - const apmUsageCollector = usageCollector.makeUsageCollector({ + const collector = usageCollector.makeUsageCollector({ type: 'apm', fetch: async () => { - try { - const apmTelemetrySavedObject = await savedObjectsRepository.get( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID - ); - return apmTelemetrySavedObject.attributes; - } catch (err) { - return createApmTelementry(); - } + const data = ( + await savedObjectsClient.get( + APM_TELEMETRY_SAVED_OBJECT_TYPE, + APM_TELEMETRY_SAVED_OBJECT_ID + ) + ).attributes; + + return data; }, isReady: () => true }); - usageCollector.registerCollector(apmUsageCollector); + usageCollector.registerCollector(collector); + + core.getStartServices().then(([coreStart, pluginsStart]) => { + const { taskManager: taskManagerStart } = pluginsStart as { + taskManager: TaskManagerStartContract; + }; + + taskManagerStart.ensureScheduled({ + id: APM_TELEMETRY_TASK_NAME, + taskType: APM_TELEMETRY_TASK_NAME, + schedule: { + interval: '720m' + }, + scope: ['apm'], + params: {}, + state: {} + }); + }); } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts new file mode 100644 index 0000000000000..f68dc517a2227 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts @@ -0,0 +1,118 @@ +/* + * 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 { DeepPartial } from 'utility-types'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; + +export interface TimeframeMap { + '1d': number; + all: number; +} + +export type TimeframeMap1d = Pick; +export type TimeframeMapAll = Pick; + +export type APMDataTelemetry = DeepPartial<{ + has_any_services: boolean; + services_per_agent: Record; + versions: { + apm_server: { + minor: number; + major: number; + patch: number; + }; + }; + counts: { + transaction: TimeframeMap; + span: TimeframeMap; + error: TimeframeMap; + metric: TimeframeMap; + sourcemap: TimeframeMap; + onboarding: TimeframeMap; + agent_configuration: TimeframeMapAll; + max_transaction_groups_per_service: TimeframeMap; + max_error_groups_per_service: TimeframeMap; + traces: TimeframeMap; + services: TimeframeMap; + }; + cardinality: { + user_agent: { + original: { + all_agents: TimeframeMap1d; + rum: TimeframeMap1d; + }; + }; + transaction: { + name: { + all_agents: TimeframeMap1d; + rum: TimeframeMap1d; + }; + }; + }; + retainment: Record< + 'span' | 'transaction' | 'error' | 'metric' | 'sourcemap' | 'onboarding', + { ms: number } + >; + integrations: { + ml: { + all_jobs_count: number; + }; + }; + agents: Record< + AgentName, + { + agent: { + version: string[]; + }; + service: { + framework: { + name: string[]; + version: string[]; + composite: string[]; + }; + language: { + name: string[]; + version: string[]; + composite: string[]; + }; + runtime: { + name: string[]; + version: string[]; + composite: string[]; + }; + }; + } + >; + indices: { + shards: { + total: number; + }; + all: { + total: { + docs: { + count: number; + }; + store: { + size_in_bytes: number; + }; + }; + }; + }; + tasks: Record< + | 'processor_events' + | 'agent_configuration' + | 'services' + | 'versions' + | 'groupings' + | 'integrations' + | 'agents' + | 'indices_stats' + | 'cardinality', + { took: { ms: number } } + >; +}>; + +export type APMTelemetry = APMDataTelemetry; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 40a2a0e7216a0..8e8cf698a84cf 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -39,6 +39,19 @@ function getMockRequest() { _debug: false } }, + __LEGACY: { + server: { + plugins: { + elasticsearch: { + getCluster: jest.fn().mockReturnValue({ callWithInternalUser: {} }) + } + }, + savedObjects: { + SavedObjectsClient: jest.fn(), + getSavedObjectsRepository: jest.fn() + } + } + }, core: { elasticsearch: { dataClient: { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index e140340786e8a..e18b6d33ca419 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -13,7 +13,6 @@ import { TaskManagerSetupContract } from '../../task_manager/server'; import { AlertingPlugin } from '../../alerting/server'; import { ActionsPlugin } from '../../actions/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; -import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; import { createApmApi } from './routes/create_apm_api'; @@ -25,6 +24,7 @@ import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { LicensingPluginSetup } from '../../licensing/public'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; +import { createApmTelemetry } from './lib/apm_telemetry'; export interface LegacySetup { server: Server; @@ -56,7 +56,7 @@ export class APMPlugin implements Plugin { actions?: ActionsPlugin['setup']; } ) { - const logger = this.initContext.logger.get('apm'); + const logger = this.initContext.logger.get(); const config$ = this.initContext.config.create(); const mergedConfig$ = combineLatest(plugins.apm_oss.config$, config$).pipe( map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) @@ -76,6 +76,20 @@ export class APMPlugin implements Plugin { const currentConfig = await mergedConfig$.pipe(take(1)).toPromise(); + if ( + plugins.taskManager && + plugins.usageCollection && + currentConfig['xpack.apm.telemetryCollectionEnabled'] + ) { + createApmTelemetry({ + core, + config$: mergedConfig$, + usageCollector: plugins.usageCollection, + taskManager: plugins.taskManager, + logger + }); + } + // create agent configuration index without blocking setup lifecycle createApmAgentConfigurationIndex({ esClient: core.elasticsearch.dataClient, @@ -104,18 +118,6 @@ export class APMPlugin implements Plugin { }) ); - const usageCollection = plugins.usageCollection; - if (usageCollection) { - getInternalSavedObjectsClient(core) - .then(savedObjectsClient => { - makeApmUsageCollector(usageCollection, savedObjectsClient); - }) - .catch(error => { - logger.error('Unable to initialize use collection'); - logger.error(error.message); - }); - } - return { config$: mergedConfig$, registerLegacyAPI: once((__LEGACY: LegacySetup) => { @@ -130,6 +132,7 @@ export class APMPlugin implements Plugin { }; } - public start() {} + public async start() {} + public stop() {} } diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index e639bb5101e2f..312dae1d1f9d2 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -36,6 +36,7 @@ const getCoreMock = () => { put, createRouter, context: { + measure: () => undefined, config$: new BehaviorSubject({} as APMConfig), logger: ({ error: jest.fn() diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 2d4fae9d2707a..1c6561ee24c93 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,11 +5,6 @@ */ import * as t from 'io-ts'; -import { AgentName } from '../../typings/es_schemas/ui/fields/agent'; -import { - createApmTelementry, - storeApmServicesTelemetry -} from '../lib/apm_telemetry'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServices } from '../lib/services/get_services'; @@ -18,7 +13,6 @@ import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadat import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; -import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; export const servicesRoute = createRoute(core => ({ path: '/api/apm/services', @@ -29,16 +23,6 @@ export const servicesRoute = createRoute(core => ({ const setup = await setupRequest(context, request); const services = await getServices(setup); - // Store telemetry data derived from services - const agentNames = services.items.map( - ({ agentName }) => agentName as AgentName - ); - const apmTelemetry = createApmTelementry(agentNames); - const savedObjectsClient = await getInternalSavedObjectsClient(core); - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry).catch(error => { - context.logger.error(error.message); - }); - return services; } })); diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 6d3620f11a87b..8a8d256cf4273 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -126,6 +126,16 @@ export interface AggregationOptionsByType { combine_script: Script; reduce_script: Script; }; + date_range: { + field: string; + format?: string; + ranges: Array< + | { from: string | number } + | { to: string | number } + | { from: string | number; to: string | number } + >; + keyed?: boolean; + }; } type AggregationType = keyof AggregationOptionsByType; @@ -136,6 +146,15 @@ type AggregationOptionsMap = Unionize< } > & { aggs?: AggregationInputMap }; +interface DateRangeBucket { + key: string; + to?: number; + from?: number; + to_as_string?: string; + from_as_string?: string; + doc_count: number; +} + export interface AggregationInputMap { [key: string]: AggregationOptionsMap; } @@ -276,6 +295,11 @@ interface AggregationResponsePart< scripted_metric: { value: unknown; }; + date_range: { + buckets: TAggregationOptionsMap extends { date_range: { keyed: true } } + ? Record + : { buckets: DateRangeBucket[] }; + }; } // Type for debugging purposes. If you see an error in AggregationResponseMap @@ -285,7 +309,7 @@ interface AggregationResponsePart< // type MissingAggregationResponseTypes = Exclude< // AggregationType, -// keyof AggregationResponsePart<{}> +// keyof AggregationResponsePart<{}, unknown> // >; export type AggregationResponseMap< diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts index 485ad18019c12..c189c845834a4 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts @@ -15,6 +15,7 @@ import { Service } from './fields/service'; import { IStackframe } from './fields/stackframe'; import { Url } from './fields/url'; import { User } from './fields/user'; +import { Observer } from './fields/observer'; interface Processor { name: 'error'; @@ -64,4 +65,5 @@ export interface ErrorRaw extends APMBaseDoc { service: Service; url?: Url; user?: User; + observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts new file mode 100644 index 0000000000000..42843130ec47f --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts @@ -0,0 +1,10 @@ +/* + * 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 interface Observer { + version: string; + version_major: number; +} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts index 954e274b48c1f..7fc17151c8a89 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts @@ -6,6 +6,7 @@ import { APMBaseDoc } from './apm_base_doc'; import { IStackframe } from './fields/stackframe'; +import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -52,4 +53,5 @@ export interface SpanRaw extends APMBaseDoc { transaction?: { id: string; }; + observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index 03cbd387d4fd4..84b7d3be33c92 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -15,6 +15,7 @@ import { Service } from './fields/service'; import { Url } from './fields/url'; import { User } from './fields/user'; import { UserAgent } from './fields/user_agent'; +import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -63,4 +64,5 @@ export interface TransactionRaw extends APMBaseDoc { url?: Url; user?: User; user_agent?: UserAgent; + observer?: Observer; } From c2109b8a231c6b52df62a712e4bea477b828fe45 Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Tue, 24 Mar 2020 16:30:08 +0100 Subject: [PATCH 05/12] [SIEM][Detection Engine] Add rule's notification alert type (#60832) (#61068) --- .../legacy/plugins/siem/common/constants.ts | 8 + .../transform_actions.test.ts | 0 .../detection_engine}/transform_actions.ts | 4 +- .../siem/common/detection_engine/types.ts | 11 ++ .../notifications/add_tags.test.ts | 26 +++ .../notifications/add_tags.ts | 10 ++ .../notifications/build_signals_query.test.ts | 53 ++++++ .../notifications/build_signals_query.ts | 42 +++++ .../create_notifications.test.ts | 73 +++++++++ .../notifications/create_notifications.ts | 36 ++++ .../delete_notifications.test.ts | 141 ++++++++++++++++ .../notifications/delete_notifications.ts | 37 +++++ .../notifications/find_notifications.test.ts | 20 +++ .../notifications/find_notifications.ts | 37 +++++ .../notifications/get_signals_count.ts | 66 ++++++++ .../notifications/read_notifications.test.ts | 154 ++++++++++++++++++ .../notifications/read_notifications.ts | 48 ++++++ .../rules_notification_alert_type.test.ts | 142 ++++++++++++++++ .../rules_notification_alert_type.ts | 64 ++++++++ .../schedule_notification_actions.ts | 34 ++++ .../notifications/types.test.ts | 26 +++ .../detection_engine/notifications/types.ts | 107 ++++++++++++ .../update_notifications.test.ts | 137 ++++++++++++++++ .../notifications/update_notifications.ts | 64 ++++++++ .../notifications/utils.test.ts | 21 +++ .../detection_engine/notifications/utils.ts | 18 ++ .../routes/__mocks__/request_responses.ts | 68 +++++++- .../routes/rules/create_rules_route.test.ts | 15 ++ .../routes/rules/create_rules_route.ts | 13 ++ .../routes/rules/delete_rules_bulk_route.ts | 2 + .../routes/rules/delete_rules_route.ts | 2 + .../routes/rules/update_rules_route.ts | 11 ++ .../detection_engine/routes/rules/utils.ts | 2 +- .../add_prepackaged_rules_schema.test.ts | 3 +- .../schemas/create_rules_schema.test.ts | 3 +- .../schemas/import_rules_schema.test.ts | 3 +- .../routes/schemas/patch_rules_schema.test.ts | 3 +- .../schemas/update_rules_schema.test.ts | 3 +- .../detection_engine/rules/create_rules.ts | 4 +- .../rules/delete_rules.test.ts | 150 +++++++++++++++++ .../rules/patch_rules.test.ts | 55 ++++++- .../lib/detection_engine/rules/patch_rules.ts | 2 +- .../detection_engine/rules/read_rules.test.ts | 2 +- .../rules/update_rules.test.ts | 55 ++++++- .../detection_engine/rules/update_rules.ts | 2 +- .../signals/build_bulk_body.ts | 3 +- .../detection_engine/signals/build_rule.ts | 3 +- .../signals/bulk_create_ml_signals.ts | 3 +- .../signals/get_filter.test.ts | 14 ++ .../signals/search_after_bulk_create.ts | 3 +- .../signals/signal_rule_alert_type.ts | 47 +++++- .../signals/single_bulk_create.ts | 3 +- .../lib/detection_engine/signals/types.ts | 7 +- .../siem/server/lib/detection_engine/types.ts | 9 +- x-pack/legacy/plugins/siem/server/plugin.ts | 16 +- 55 files changed, 1850 insertions(+), 35 deletions(-) rename x-pack/legacy/plugins/siem/{server/lib/detection_engine/rules => common/detection_engine}/transform_actions.test.ts (100%) rename x-pack/legacy/plugins/siem/{server/lib/detection_engine/rules => common/detection_engine}/transform_actions.ts (83%) create mode 100644 x-pack/legacy/plugins/siem/common/detection_engine/types.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index c3fc4aea77863..ec720164e9bd7 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -52,12 +52,18 @@ export const IP_REPUTATION_LINKS_SETTING_DEFAULT = `[ */ export const SIGNALS_ID = `${APP_ID}.signals`; +/** + * Id for the notifications alerting type + */ +export const NOTIFICATIONS_ID = `${APP_ID}.notifications`; + /** * Special internal structure for tags for signals. This is used * to filter out tags that have internal structures within them. */ export const INTERNAL_IDENTIFIER = '__internal'; export const INTERNAL_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_id`; +export const INTERNAL_RULE_ALERT_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_alert_id`; export const INTERNAL_IMMUTABLE_KEY = `${INTERNAL_IDENTIFIER}_immutable`; /** @@ -87,3 +93,5 @@ export const DETECTION_ENGINE_QUERY_SIGNALS_URL = `${DETECTION_ENGINE_SIGNALS_UR * Common naming convention for an unauthenticated user */ export const UNAUTHENTICATED_USER = 'Unauthenticated'; + +export const NOTIFICATION_THROTTLE_RULE = 'rule'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.test.ts similarity index 100% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.test.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts similarity index 83% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts index c1c17d2c70836..aeb4d53933022 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts +++ b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertAction } from '../../../../../../../plugins/alerting/common'; -import { RuleAlertAction } from '../types'; +import { AlertAction } from '../../../../../plugins/alerting/common'; +import { RuleAlertAction } from './types'; export const transformRuleToAlertAction = ({ group, diff --git a/x-pack/legacy/plugins/siem/common/detection_engine/types.ts b/x-pack/legacy/plugins/siem/common/detection_engine/types.ts new file mode 100644 index 0000000000000..0de370b11cdaf --- /dev/null +++ b/x-pack/legacy/plugins/siem/common/detection_engine/types.ts @@ -0,0 +1,11 @@ +/* + * 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 { AlertAction } from '../../../../../plugins/alerting/common'; + +export type RuleAlertAction = Omit & { + action_type_id: string; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts new file mode 100644 index 0000000000000..e14d20e3bc56e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { addTags } from './add_tags'; +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +describe('add_tags', () => { + test('it should add a rule id as an internal structure', () => { + const tags = addTags([], 'rule-1'); + expect(tags).toEqual([`${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); + + test('it should not allow duplicate tags to be created', () => { + const tags = addTags(['tag-1', 'tag-1'], 'rule-1'); + expect(tags).toEqual(['tag-1', `${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); + + test('it should not allow duplicate internal tags to be created when called two times in a row', () => { + const tags1 = addTags(['tag-1'], 'rule-1'); + const tags2 = addTags(tags1, 'rule-1'); + expect(tags2).toEqual(['tag-1', `${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts new file mode 100644 index 0000000000000..6955e57d099be --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts @@ -0,0 +1,10 @@ +/* + * 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 { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +export const addTags = (tags: string[] = [], ruleAlertId: string): string[] => + Array.from(new Set([...tags, `${INTERNAL_RULE_ALERT_ID_KEY}:${ruleAlertId}`])); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts new file mode 100644 index 0000000000000..f83a8d40d6ae1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.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 { buildSignalsSearchQuery } from './build_signals_query'; + +describe('buildSignalsSearchQuery', () => { + it('returns proper query object', () => { + const index = 'index'; + const ruleId = 'ruleId-12'; + const from = '123123123'; + const to = '1123123123'; + + expect( + buildSignalsSearchQuery({ + index, + from, + to, + ruleId, + }) + ).toEqual({ + index, + body: { + query: { + bool: { + filter: [ + { + bool: { + should: { + match: { + 'signal.rule.rule_id': ruleId, + }, + }, + minimum_should_match: 1, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts new file mode 100644 index 0000000000000..001650e5b2005 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface BuildSignalsSearchQuery { + ruleId: string; + index: string; + from: string; + to: string; +} + +export const buildSignalsSearchQuery = ({ ruleId, index, from, to }: BuildSignalsSearchQuery) => ({ + index, + body: { + query: { + bool: { + filter: [ + { + bool: { + should: { + match: { + 'signal.rule.rule_id': ruleId, + }, + }, + minimum_should_match: 1, + }, + }, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + }, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts new file mode 100644 index 0000000000000..dea42b0c852f9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts @@ -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 { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { createNotifications } from './create_notifications'; + +describe('createNotifications', () => { + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('calls the alertsClient with proper params', async () => { + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + await createNotifications({ + alertsClient, + actions: [], + ruleAlertId, + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + ruleAlertId, + }), + }), + }) + ); + }); + + it('calls the alertsClient with transformed actions', async () => { + const action = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signalsCount}} signals' }, + action_type_id: '.slack', + }; + await createNotifications({ + alertsClient, + actions: [action], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actions: expect.arrayContaining([ + { + group: action.group, + id: action.id, + params: action.params, + actionTypeId: '.slack', + }, + ]), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts new file mode 100644 index 0000000000000..3a1697f1c8afc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts @@ -0,0 +1,36 @@ +/* + * 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 { Alert } from '../../../../../../../plugins/alerting/common'; +import { APP_ID, NOTIFICATIONS_ID } from '../../../../common/constants'; +import { CreateNotificationParams } from './types'; +import { addTags } from './add_tags'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; + +export const createNotifications = async ({ + alertsClient, + actions, + enabled, + ruleAlertId, + interval, + name, + tags, +}: CreateNotificationParams): Promise => + alertsClient.create({ + data: { + name, + tags: addTags(tags, ruleAlertId), + alertTypeId: NOTIFICATIONS_ID, + consumer: APP_ID, + params: { + ruleAlertId, + }, + schedule: { interval }, + enabled, + actions: actions?.map(transformRuleToAlertAction), + throttle: null, + }, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts new file mode 100644 index 0000000000000..7e5c0eaf6286e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts @@ -0,0 +1,141 @@ +/* + * 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 { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { deleteNotifications } from './delete_notifications'; +import { readNotifications } from './read_notifications'; +jest.mock('./read_notifications'); + +describe('deleteNotifications', () => { + let alertsClient: ReturnType; + const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('should return null if notification was not found', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(result).toBe(null); + }); + + it('should call alertsClient.delete if notification was found', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: notificationId, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: notificationId }); + }); + + it('should call alertsClient.delete if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: null }); + }); + + it('should return null if alertsClient.delete rejects with 404 if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + alertsClient.delete.mockRejectedValue({ + output: { + statusCode: 404, + }, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual(null); + }); + + it('should return error object if alertsClient.delete rejects with status different than 404 and if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const errorObject = { + output: { + statusCode: 500, + }, + }; + + alertsClient.delete.mockRejectedValue(errorObject); + + let errorResult; + try { + await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + } catch (error) { + errorResult = error; + } + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(errorResult).toEqual(errorObject); + }); + + it('should return null if notification.id and id were null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteNotifications({ + alertsClient, + id: undefined, + ruleAlertId, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts new file mode 100644 index 0000000000000..7e244f96f1649 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { readNotifications } from './read_notifications'; +import { DeleteNotificationParams } from './types'; + +export const deleteNotifications = async ({ + alertsClient, + id, + ruleAlertId, +}: DeleteNotificationParams) => { + const notification = await readNotifications({ alertsClient, id, ruleAlertId }); + if (notification == null) { + return null; + } + + if (notification.id != null) { + await alertsClient.delete({ id: notification.id }); + return notification; + } else if (id != null) { + try { + await alertsClient.delete({ id }); + return notification; + } catch (err) { + if (err.output.statusCode === 404) { + return null; + } else { + throw err; + } + } + } else { + return null; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts new file mode 100644 index 0000000000000..0e9e4a8370ec8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getFilter } from './find_notifications'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; + +describe('find_notifications', () => { + test('it returns a full filter with an AND if sent down', () => { + expect(getFilter('alert.attributes.enabled: true')).toEqual( + `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID} AND alert.attributes.enabled: true` + ); + }); + + test('it returns existing filter with no AND when not set', () => { + expect(getFilter(null)).toEqual(`alert.attributes.alertTypeId: ${NOTIFICATIONS_ID}`); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts new file mode 100644 index 0000000000000..fcdeda608fe4e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FindResult } from '../../../../../../../plugins/alerting/server'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; +import { FindNotificationParams } from './types'; + +export const getFilter = (filter: string | null | undefined) => { + if (filter == null) { + return `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID}`; + } else { + return `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID} AND ${filter}`; + } +}; + +export const findNotifications = async ({ + alertsClient, + perPage, + page, + fields, + filter, + sortField, + sortOrder, +}: FindNotificationParams): Promise => + alertsClient.find({ + options: { + fields, + page, + perPage, + filter: getFilter(filter), + sortOrder, + sortField, + }, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts new file mode 100644 index 0000000000000..6ae7922660bd7 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.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 moment from 'moment'; +import { getNotificationResultsLink } from './utils'; +import { NotificationExecutorOptions } from './types'; +import { parseScheduleDates } from '../signals/utils'; +import { buildSignalsSearchQuery } from './build_signals_query'; + +interface SignalsCountResults { + signalsCount: string; + resultsLink: string; +} + +interface GetSignalsCount { + from: Date | string; + to: Date | string; + ruleAlertId: string; + ruleId: string; + index: string; + kibanaUrl: string | undefined; + callCluster: NotificationExecutorOptions['services']['callCluster']; +} + +export const getSignalsCount = async ({ + from, + to, + ruleAlertId, + ruleId, + index, + callCluster, + kibanaUrl = '', +}: GetSignalsCount): Promise => { + const fromMoment = moment.isDate(from) ? moment(from) : parseScheduleDates(from); + const toMoment = moment.isDate(to) ? moment(to) : parseScheduleDates(to); + + if (!fromMoment || !toMoment) { + throw new Error(`There was an issue with parsing ${from} or ${to} into Moment object`); + } + + const fromInMs = fromMoment.format('x'); + const toInMs = toMoment.format('x'); + + const query = buildSignalsSearchQuery({ + index, + ruleId, + to: toInMs, + from: fromInMs, + }); + + const result = await callCluster('count', query); + const resultsLink = getNotificationResultsLink({ + baseUrl: kibanaUrl, + id: ruleAlertId, + from: fromInMs, + to: toInMs, + }); + + return { + signalsCount: result.count, + resultsLink, + }; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts new file mode 100644 index 0000000000000..834ad2460959c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts @@ -0,0 +1,154 @@ +/* + * 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 { readNotifications } from './read_notifications'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { + getNotificationResult, + getFindNotificationsResultWithSingleHit, +} from '../routes/__mocks__/request_responses'; + +class TestError extends Error { + constructor() { + super(); + + this.name = 'CustomError'; + this.output = { statusCode: 404 }; + } + public output: { statusCode: number }; +} + +describe('read_notifications', () => { + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + describe('readNotifications', () => { + test('should return the output from alertsClient if id is set but ruleAlertId is undefined', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(getNotificationResult()); + }); + test('should return null if saved object found by alerts client given id is not alert type', async () => { + const result = getNotificationResult(); + delete result.alertTypeId; + alertsClient.get.mockResolvedValue(result); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + + test('should return error if alerts client throws 404 error on get', async () => { + alertsClient.get.mockImplementation(() => { + throw new TestError(); + }); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + + test('should return error if alerts client throws error on get', async () => { + alertsClient.get.mockImplementation(() => { + throw new Error('Test error'); + }); + try { + await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + } catch (exc) { + expect(exc.message).toEqual('Test error'); + } + }); + + test('should return the output from alertsClient if id is set but ruleAlertId is null', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: null, + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return the output from alertsClient if id is undefined but ruleAlertId is set', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return null if the output from alertsClient with ruleAlertId set is empty', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue({ data: [], page: 0, perPage: 1, total: 0 }); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(null); + }); + + test('should return the output from alertsClient if id is null but ruleAlertId is set', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: null, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return null if id and ruleAlertId are null', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: null, + ruleAlertId: null, + }); + expect(rule).toEqual(null); + }); + + test('should return null if id and ruleAlertId are undefined', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts new file mode 100644 index 0000000000000..87bdd6f3f40e1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts @@ -0,0 +1,48 @@ +/* + * 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 { SanitizedAlert } from '../../../../../../../plugins/alerting/common'; +import { ReadNotificationParams, isAlertType } from './types'; +import { findNotifications } from './find_notifications'; +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +export const readNotifications = async ({ + alertsClient, + id, + ruleAlertId, +}: ReadNotificationParams): Promise => { + if (id != null) { + try { + const notification = await alertsClient.get({ id }); + if (isAlertType(notification)) { + return notification; + } else { + return null; + } + } catch (err) { + if (err?.output?.statusCode === 404) { + return null; + } else { + // throw non-404 as they would be 500 or other internal errors + throw err; + } + } + } else if (ruleAlertId != null) { + const notificationFromFind = await findNotifications({ + alertsClient, + filter: `alert.attributes.tags: "${INTERNAL_RULE_ALERT_ID_KEY}:${ruleAlertId}"`, + page: 1, + }); + if (notificationFromFind.data.length === 0 || !isAlertType(notificationFromFind.data[0])) { + return null; + } else { + return notificationFromFind.data[0]; + } + } else { + // should never get here, and yet here we are. + return null; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts new file mode 100644 index 0000000000000..ff0126b129636 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { savedObjectsClientMock } from 'src/core/server/mocks'; +import { loggerMock } from 'src/core/server/logging/logger.mock'; +import { getResult } from '../routes/__mocks__/request_responses'; +import { rulesNotificationAlertType } from './rules_notification_alert_type'; +import { buildSignalsSearchQuery } from './build_signals_query'; +import { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { NotificationExecutorOptions } from './types'; +jest.mock('./build_signals_query'); + +describe('rules_notification_alert_type', () => { + let payload: NotificationExecutorOptions; + let alert: ReturnType; + let alertInstanceMock: Record; + let alertInstanceFactoryMock: () => AlertInstance; + let savedObjectsClient: ReturnType; + let logger: ReturnType; + let callClusterMock: jest.Mock; + + beforeEach(() => { + alertInstanceMock = { + scheduleActions: jest.fn(), + replaceState: jest.fn(), + }; + alertInstanceMock.replaceState.mockReturnValue(alertInstanceMock); + alertInstanceFactoryMock = jest.fn().mockReturnValue(alertInstanceMock); + callClusterMock = jest.fn(); + savedObjectsClient = savedObjectsClientMock.create(); + logger = loggerMock.create(); + + payload = { + alertId: '1111', + services: { + savedObjectsClient, + alertInstanceFactory: alertInstanceFactoryMock, + callCluster: callClusterMock, + }, + params: { ruleAlertId: '2222' }, + state: {}, + spaceId: '', + name: 'name', + tags: [], + startedAt: new Date('2019-12-14T16:40:33.400Z'), + previousStartedAt: new Date('2019-12-13T16:40:33.400Z'), + createdBy: 'elastic', + updatedBy: 'elastic', + }; + + alert = rulesNotificationAlertType({ + logger, + }); + }); + + describe('executor', () => { + it('throws an error if rule alert was not found', async () => { + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + attributes: {}, + type: 'type', + references: [], + }); + await alert.executor(payload); + expect(logger.error).toHaveBeenCalledWith( + `Saved object for alert ${payload.params.ruleAlertId} was not found` + ); + }); + + it('should call buildSignalsSearchQuery with proper params', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 0, + }); + + await alert.executor(payload); + + expect(buildSignalsSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + from: '1576255233400', + index: '.siem-signals', + ruleId: 'rule-1', + to: '1576341633400', + }) + ); + }); + + it('should not call alertInstanceFactory if signalsCount was 0', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 0, + }); + + await alert.executor(payload); + + expect(alertInstanceFactoryMock).not.toHaveBeenCalled(); + }); + + it('should call scheduleActions if signalsCount was greater than 0', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 10, + }); + + await alert.executor(payload); + + expect(alertInstanceFactoryMock).toHaveBeenCalled(); + expect(alertInstanceMock.replaceState).toHaveBeenCalledWith( + expect.objectContaining({ signalsCount: 10 }) + ); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + rule: expect.objectContaining({ + name: ruleAlert.name, + }), + }) + ); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts new file mode 100644 index 0000000000000..c5dc4c3a27e16 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts @@ -0,0 +1,64 @@ +/* + * 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 { Logger } from 'src/core/server'; +import { schema } from '@kbn/config-schema'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; + +import { NotificationAlertTypeDefinition } from './types'; +import { getSignalsCount } from './get_signals_count'; +import { RuleAlertAttributes } from '../signals/types'; +import { siemRuleActionGroups } from '../signals/siem_rule_action_groups'; +import { scheduleNotificationActions } from './schedule_notification_actions'; + +export const rulesNotificationAlertType = ({ + logger, +}: { + logger: Logger; +}): NotificationAlertTypeDefinition => ({ + id: NOTIFICATIONS_ID, + name: 'SIEM Notifications', + actionGroups: siemRuleActionGroups, + defaultActionGroupId: 'default', + validate: { + params: schema.object({ + ruleAlertId: schema.string(), + }), + }, + async executor({ startedAt, previousStartedAt, alertId, services, params }) { + const ruleAlertSavedObject = await services.savedObjectsClient.get( + 'alert', + params.ruleAlertId + ); + + if (!ruleAlertSavedObject.attributes.params) { + logger.error(`Saved object for alert ${params.ruleAlertId} was not found`); + return; + } + + const { params: ruleAlertParams, name: ruleName } = ruleAlertSavedObject.attributes; + const ruleParams = { ...ruleAlertParams, name: ruleName }; + + const { signalsCount, resultsLink } = await getSignalsCount({ + from: previousStartedAt ?? `now-${ruleParams.interval}`, + to: startedAt, + index: ruleParams.outputIndex, + ruleId: ruleParams.ruleId!, + kibanaUrl: ruleAlertParams.meta?.kibanaUrl as string, + ruleAlertId: ruleAlertSavedObject.id, + callCluster: services.callCluster, + }); + + logger.info( + `Found ${signalsCount} signals using signal rule name: "${ruleParams.name}", id: "${params.ruleAlertId}", rule_id: "${ruleParams.ruleId}" in "${ruleParams.outputIndex}" index` + ); + + if (signalsCount) { + const alertInstance = services.alertInstanceFactory(alertId); + scheduleNotificationActions({ alertInstance, signalsCount, resultsLink, ruleParams }); + } + }, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts new file mode 100644 index 0000000000000..9c38c88a12411 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts @@ -0,0 +1,34 @@ +/* + * 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 { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { RuleTypeParams } from '../types'; + +type NotificationRuleTypeParams = RuleTypeParams & { + name: string; +}; + +interface ScheduleNotificationActions { + alertInstance: AlertInstance; + signalsCount: string; + resultsLink: string; + ruleParams: NotificationRuleTypeParams; +} + +export const scheduleNotificationActions = ({ + alertInstance, + signalsCount, + resultsLink, + ruleParams, +}: ScheduleNotificationActions): AlertInstance => + alertInstance + .replaceState({ + signalsCount, + }) + .scheduleActions('default', { + resultsLink, + rule: ruleParams, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts new file mode 100644 index 0000000000000..4fce037b483d5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loggerMock } from 'src/core/server/logging/logger.mock'; +import { getNotificationResult, getResult } from '../routes/__mocks__/request_responses'; +import { isAlertTypes, isNotificationAlertExecutor } from './types'; +import { rulesNotificationAlertType } from './rules_notification_alert_type'; + +describe('types', () => { + it('isAlertTypes should return true if is RuleNotificationAlertType type', () => { + expect(isAlertTypes([getNotificationResult()])).toEqual(true); + }); + + it('isAlertTypes should return false if is not RuleNotificationAlertType', () => { + expect(isAlertTypes([getResult()])).toEqual(false); + }); + + it('isNotificationAlertExecutor should return true it passed object is NotificationAlertTypeDefinition type', () => { + expect( + isNotificationAlertExecutor(rulesNotificationAlertType({ logger: loggerMock.create() })) + ).toEqual(true); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts new file mode 100644 index 0000000000000..edcd821353bc8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts @@ -0,0 +1,107 @@ +/* + * 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 { + AlertsClient, + PartialAlert, + AlertType, + State, + AlertExecutorOptions, +} from '../../../../../../../plugins/alerting/server'; +import { Alert } from '../../../../../../../plugins/alerting/common'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; + +export interface RuleNotificationAlertType extends Alert { + params: { + ruleAlertId: string; + }; +} + +export interface FindNotificationParams { + alertsClient: AlertsClient; + perPage?: number; + page?: number; + sortField?: string; + filter?: string; + fields?: string[]; + sortOrder?: 'asc' | 'desc'; +} + +export interface FindNotificationsRequestParams { + per_page: number; + page: number; + search?: string; + sort_field?: string; + filter?: string; + fields?: string[]; + sort_order?: 'asc' | 'desc'; +} + +export interface Clients { + alertsClient: AlertsClient; +} + +export type UpdateNotificationParams = Omit & { + actions: RuleAlertAction[]; + id?: string; + tags?: string[]; + interval: string | null; + ruleAlertId: string; +} & Clients; + +export type DeleteNotificationParams = Clients & { + id?: string; + ruleAlertId?: string; +}; + +export interface NotificationAlertParams { + actions: RuleAlertAction[]; + enabled: boolean; + ruleAlertId: string; + interval: string; + name: string; + tags?: string[]; + throttle?: null; +} + +export type CreateNotificationParams = NotificationAlertParams & Clients; + +export interface ReadNotificationParams { + alertsClient: AlertsClient; + id?: string | null; + ruleAlertId?: string | null; +} + +export const isAlertTypes = ( + partialAlert: PartialAlert[] +): partialAlert is RuleNotificationAlertType[] => { + return partialAlert.every(rule => isAlertType(rule)); +}; + +export const isAlertType = ( + partialAlert: PartialAlert +): partialAlert is RuleNotificationAlertType => { + return partialAlert.alertTypeId === NOTIFICATIONS_ID; +}; + +export type NotificationExecutorOptions = Omit & { + params: { + ruleAlertId: string; + }; +}; + +// This returns true because by default a NotificationAlertTypeDefinition is an AlertType +// since we are only increasing the strictness of params. +export const isNotificationAlertExecutor = ( + obj: NotificationAlertTypeDefinition +): obj is AlertType => { + return true; +}; + +export type NotificationAlertTypeDefinition = Omit & { + executor: ({ services, params, state }: NotificationExecutorOptions) => Promise; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts new file mode 100644 index 0000000000000..e1b452c911443 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { updateNotifications } from './update_notifications'; +import { readNotifications } from './read_notifications'; +import { createNotifications } from './create_notifications'; +import { getNotificationResult } from '../routes/__mocks__/request_responses'; +jest.mock('./read_notifications'); +jest.mock('./create_notifications'); + +describe('updateNotifications', () => { + const notification = getNotificationResult(); + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('should update the existing notification if interval provided', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + + await updateNotifications({ + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: notification.id, + data: expect.objectContaining({ + params: expect.objectContaining({ + ruleAlertId: 'new-rule-id', + }), + }), + }) + ); + }); + + it('should create a new notification if did not exist', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + + const params = { + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }; + + await updateNotifications(params); + + expect(createNotifications).toHaveBeenCalledWith(expect.objectContaining(params)); + }); + + it('should delete notification if notification was found and interval is null', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + + await updateNotifications({ + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: null, + name: '', + tags: [], + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notification.id, + }) + ); + }); + + it('should call the alertsClient with transformed actions', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + const action = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signalsCount}} signals' }, + action_type_id: '.slack', + }; + await updateNotifications({ + alertsClient, + actions: [action], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actions: expect.arrayContaining([ + { + group: action.group, + id: action.id, + params: action.params, + actionTypeId: '.slack', + }, + ]), + }), + }) + ); + }); + + it('returns null if notification was not found and interval was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + const result = await updateNotifications({ + alertsClient, + actions: [], + enabled: true, + id: notification.id, + ruleAlertId, + name: notification.name, + tags: notification.tags, + interval: null, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts new file mode 100644 index 0000000000000..3197d21c0e95a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts @@ -0,0 +1,64 @@ +/* + * 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 { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { readNotifications } from './read_notifications'; +import { UpdateNotificationParams } from './types'; +import { addTags } from './add_tags'; +import { createNotifications } from './create_notifications'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; + +export const updateNotifications = async ({ + alertsClient, + actions, + enabled, + id, + ruleAlertId, + name, + tags, + interval, +}: UpdateNotificationParams): Promise => { + const notification = await readNotifications({ alertsClient, id, ruleAlertId }); + + if (interval && notification) { + const result = await alertsClient.update({ + id: notification.id, + data: { + tags: addTags(tags, ruleAlertId), + name, + schedule: { + interval, + }, + actions: actions?.map(transformRuleToAlertAction), + params: { + ruleAlertId, + }, + throttle: null, + }, + }); + return result; + } + + if (interval && !notification) { + const result = await createNotifications({ + alertsClient, + enabled, + tags, + name, + interval, + actions, + ruleAlertId, + }); + return result; + } + + if (!interval && notification) { + await alertsClient.delete({ id: notification.id }); + return null; + } + + return null; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts new file mode 100644 index 0000000000000..4c3f311d10acc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.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 { getNotificationResultsLink } from './utils'; + +describe('utils', () => { + it('getNotificationResultsLink', () => { + const resultLink = getNotificationResultsLink({ + baseUrl: 'http://localhost:5601', + id: 'notification-id', + from: '00000', + to: '1111', + }); + expect(resultLink).toEqual( + `http://localhost:5601/app/siem#/detections/rules/id/notification-id?timerange=(global:(linkTo:!(timeline),timerange:(from:00000,kind:absolute,to:1111)),timeline:(linkTo:!(global),timerange:(from:00000,kind:absolute,to:1111)))` + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts new file mode 100644 index 0000000000000..ed502d31d2fb5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.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. + */ + +export const getNotificationResultsLink = ({ + baseUrl, + id, + from, + to, +}: { + baseUrl: string; + id: string; + from: string; + to: string; +}) => + `${baseUrl}/app/siem#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 0e0ab58a7a199..6435410f31797 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -28,6 +28,7 @@ import { } from '../../rules/types'; import { RuleAlertParamsRest, PrepackagedRules } from '../../types'; import { requestMock } from './request'; +import { RuleNotificationAlertType } from '../../notifications/types'; export const mockPrepackagedRule = (): PrepackagedRules => ({ rule_id: 'rule-1', @@ -204,11 +205,11 @@ export const getPrepackagedRulesStatusRequest = () => path: `${DETECTION_ENGINE_PREPACKAGED_URL}/_status`, }); -export interface FindHit { +export interface FindHit { page: number; perPage: number; total: number; - data: RuleAlertType[]; + data: T[]; } export const getEmptyFindResult = (): FindHit => ({ @@ -309,6 +310,27 @@ export const createMlRuleRequest = () => { }); }; +export const createRuleWithActionsRequest = () => { + const payload = typicalPayload(); + + return requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: { + ...payload, + throttle: '5m', + actions: [ + { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signalsCount}} signals' }, + action_type_id: '.slack', + }, + ], + }, + }); +}; + export const getSetSignalStatusByIdsRequest = () => requestMock.create({ method: 'post', @@ -616,3 +638,45 @@ export const getEmptyIndex = (): { _shards: Partial } => ({ export const getNonEmptyIndex = (): { _shards: Partial } => ({ _shards: { total: 1 }, }); + +export const getNotificationResult = (): RuleNotificationAlertType => ({ + id: '200dbf2f-b269-4bf9-aa85-11ba32ba73ba', + name: 'Notification for Rule Test', + tags: ['__internal_rule_alert_id:85b64e8a-2e40-4096-86af-5ac172c10825'], + alertTypeId: 'siem.notifications', + consumer: 'siem', + params: { + ruleAlertId: '85b64e8a-2e40-4096-86af-5ac172c10825', + }, + schedule: { + interval: '5m', + }, + enabled: true, + actions: [ + { + actionTypeId: '.slack', + params: { + message: 'Rule generated {{state.signalsCount}} signals\n\n{{rule.name}}\n{{resultsLink}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ], + throttle: null, + apiKey: null, + apiKeyOwner: 'elastic', + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: new Date('2020-03-21T11:15:13.530Z'), + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '62b3a130-6b70-11ea-9ce9-6b9818c4cbd7', + updatedAt: new Date('2020-03-21T12:37:08.730Z'), +}); + +export const getFindNotificationsResultWithSingleHit = (): FindHit => ({ + page: 1, + perPage: 1, + total: 1, + data: [getNotificationResult()], +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 1a4e19c2047b5..14592dd499d43 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -15,10 +15,13 @@ import { getEmptyIndex, getFindResultWithSingleHit, createMlRuleRequest, + createRuleWithActionsRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesRoute } from './create_rules_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +import { createNotifications } from '../../notifications/create_notifications'; +jest.mock('../../notifications/create_notifications'); describe('create_rules', () => { let server: ReturnType; @@ -65,6 +68,18 @@ describe('create_rules', () => { }); }); + describe('creating a Notification if throttle and actions were provided ', () => { + it('is successful', async () => { + const response = await server.inject(createRuleWithActionsRequest(), context); + expect(response.status).toEqual(200); + expect(createNotifications).toHaveBeenCalledWith( + expect.objectContaining({ + ruleAlertId: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + }); + describe('unhappy paths', () => { test('it returns a 400 if the index does not exist', async () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index cee9054cf922e..1fbbb5274d738 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -17,6 +17,7 @@ import { transformValidate } from './validate'; import { getIndexExists } from '../../index/get_index_exists'; import { createRulesSchema } from '../schemas/create_rules_schema'; import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { createNotifications } from '../../notifications/create_notifications'; export const createRulesRoute = (router: IRouter): void => { router.post( @@ -131,6 +132,18 @@ export const createRulesRoute = (router: IRouter): void => { version: 1, lists, }); + + if (throttle && actions.length) { + await createNotifications({ + alertsClient, + enabled, + name, + interval, + actions, + ruleAlertId: createdRule.id, + }); + } + const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts index c56f34588cbc6..85cfeefdceead 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts @@ -16,6 +16,7 @@ import { DeleteRulesRequestParams, } from '../../rules/types'; import { deleteRules } from '../../rules/delete_rules'; +import { deleteNotifications } from '../../notifications/delete_notifications'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; type Config = RouteConfig; @@ -57,6 +58,7 @@ export const deleteRulesBulkRoute = (router: IRouter) => { ruleId, }); if (rule != null) { + await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts index 753b281dbc09e..6fd50abd9364a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -16,6 +16,7 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; +import { deleteNotifications } from '../../notifications/delete_notifications'; export const deleteRulesRoute = (router: IRouter) => { router.delete( @@ -52,6 +53,7 @@ export const deleteRulesRoute = (router: IRouter) => { ruleId, }); if (rule != null) { + await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index 7e56c32ade92a..f8cca6494e000 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -16,6 +16,7 @@ import { getIdError } from './utils'; import { transformValidate } from './validate'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { updateRules } from '../../rules/update_rules'; +import { updateNotifications } from '../../notifications/update_notifications'; export const updateRulesRoute = (router: IRouter) => { router.put( @@ -117,7 +118,17 @@ export const updateRulesRoute = (router: IRouter) => { version, lists, }); + if (rule != null) { + await updateNotifications({ + alertsClient, + actions, + enabled, + ruleAlertId: rule.id, + interval: throttle, + name, + }); + const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index e0ecbdedaac7c..a0458dc3a133d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -29,7 +29,7 @@ import { OutputError, } from '../utils'; import { hasListsFeature } from '../../feature_flags'; -import { transformAlertToRuleAction } from '../../rules/transform_actions'; +import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions'; type PromiseFromStreams = ImportRuleAlertRest | Error; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts index 2b18e1b9bf52c..b10627d151fa2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts @@ -5,7 +5,8 @@ */ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; -import { ThreatParams, PrepackagedRules, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, PrepackagedRules } from '../../types'; import { addPrepackagedRulesSchema } from './add_prepackaged_rules_schema'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts index d9c3055512815..08bd01ee9a1a0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { createRulesSchema } from './create_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts index ffb49896ef7c7..c8e5bb981f921 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts @@ -10,7 +10,8 @@ import { importRulesQuerySchema, importRulesPayloadSchema, } from './import_rules_schema'; -import { ThreatParams, ImportRuleAlertRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, ImportRuleAlertRest } from '../../types'; import { ImportRulesRequestParams } from '../../rules/types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts index 42945e0970cba..45b5028f392b9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { patchRulesSchema } from './patch_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('patch rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts index db3709cd6b126..6f6beea7fa5fb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { updateRulesSchema } from './update_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts index db70b90d5a17c..a45b28ba3e105 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts @@ -6,12 +6,12 @@ import { Alert } from '../../../../../../../plugins/alerting/common'; import { APP_ID, SIGNALS_ID } from '../../../../common/constants'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { CreateRuleParams } from './types'; import { addTags } from './add_tags'; import { hasListsFeature } from '../feature_flags'; -import { transformRuleToAlertAction } from './transform_actions'; -export const createRules = ({ +export const createRules = async ({ alertsClient, actionsClient, // TODO: Use this actionsClient once we have actions such as email, etc... actions, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts new file mode 100644 index 0000000000000..38fc1dc5d1930 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts @@ -0,0 +1,150 @@ +/* + * 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 { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { deleteRules } from './delete_rules'; +import { readRules } from './read_rules'; +jest.mock('./read_rules'); + +describe('deleteRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; + const ruleId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + }); + + it('should return null if notification was not found', async () => { + (readRules as jest.Mock).mockResolvedValue(null); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId, + }); + + expect(result).toBe(null); + }); + + it('should call alertsClient.delete if notification was found', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: notificationId, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: notificationId }); + }); + + it('should call alertsClient.delete if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: null }); + }); + + it('should return null if alertsClient.delete rejects with 404 if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + alertsClient.delete.mockRejectedValue({ + output: { + statusCode: 404, + }, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual(null); + }); + + it('should return error object if alertsClient.delete rejects with status different than 404 and if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const errorObject = { + output: { + statusCode: 500, + }, + }; + + alertsClient.delete.mockRejectedValue(errorObject); + + let errorResult; + try { + await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + } catch (error) { + errorResult = error; + } + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(errorResult).toEqual(errorObject); + }); + + it('should return null if ruleId and id was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: undefined, + ruleId: null, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts index b424d2912ebc8..cd18bee6f606f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts @@ -7,7 +7,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; -import { getMlResult } from '../routes/__mocks__/request_responses'; +import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { patchRules } from './patch_rules'; describe('patchRules', () => { @@ -21,6 +21,59 @@ describe('patchRules', () => { savedObjectsClient = savedObjectsClientMock.create(); }); + it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue(getResult()); + + await patchRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: false, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.disable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + + it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue({ + ...getResult(), + enabled: false, + }); + + await patchRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: true, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.enable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + it('calls the alertsClient with ML params', async () => { alertsClient.get.mockResolvedValue(getMlResult()); const params = { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index 5b6fd08a9ea89..5394af526c917 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -6,12 +6,12 @@ import { defaults } from 'lodash/fp'; import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { readRules } from './read_rules'; import { PatchRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion, calculateName, calculateInterval } from './utils'; -import { transformRuleToAlertAction } from './transform_actions'; export const patchRules = async ({ alertsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts index 862ea9d2dcbe5..38a883329318b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts @@ -8,7 +8,7 @@ import { readRules } from './read_rules'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { getResult, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; -class TestError extends Error { +export class TestError extends Error { constructor() { // Pass remaining arguments (including vendor specific ones) to parent constructor super(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts index 967a32df20c3b..af00816abfc3d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts @@ -7,7 +7,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; -import { getMlResult } from '../routes/__mocks__/request_responses'; +import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { updateRules } from './update_rules'; describe('updateRules', () => { @@ -21,6 +21,59 @@ describe('updateRules', () => { savedObjectsClient = savedObjectsClientMock.create(); }); + it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue(getResult()); + + await updateRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: false, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.disable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + + it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue({ + ...getResult(), + enabled: false, + }); + + await updateRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: true, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.enable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + it('calls the alertsClient with ML params', async () => { alertsClient.get.mockResolvedValue(getMlResult()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index a80f986482010..72cbc959c0105 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -5,13 +5,13 @@ */ import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { readRules } from './read_rules'; import { IRuleSavedAttributesSavedObjectAttributes, UpdateRuleParams } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion } from './utils'; import { hasListsFeature } from '../feature_flags'; -import { transformRuleToAlertAction } from './transform_actions'; export const updateRules = async ({ alertsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts index adbd5f81d372a..f485769dffabc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts @@ -8,7 +8,8 @@ import { SignalSourceHit, SignalHit } from './types'; import { buildRule } from './build_rule'; import { buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; interface BuildBulkBodyParams { doc: SignalSourceHit; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts index e94ca18b186e4..1de80ca0b7eaf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts @@ -5,7 +5,8 @@ */ import { pickBy } from 'lodash/fp'; -import { RuleTypeParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams, OutputRuleAlertRest } from '../types'; interface BuildRuleParams { ruleParams: RuleTypeParams; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index 95adb90172404..66e9f42061658 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -9,7 +9,8 @@ import { SearchResponse } from 'elasticsearch'; import { Logger } from '../../../../../../../../src/core/server'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { singleBulkCreate } from './single_bulk_create'; import { AnomalyResults, Anomaly } from '../../machine_learning'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts index b49f43ce9e7ac..86d1278031695 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts @@ -510,6 +510,20 @@ describe('get_filter', () => { ).rejects.toThrow('savedId parameter should be defined'); }); + test('throws on machine learning query', async () => { + await expect( + getFilter({ + type: 'machine_learning', + filters: undefined, + language: undefined, + query: undefined, + savedId: 'some-id', + services: servicesMock, + index: undefined, + }) + ).rejects.toThrow('Unsupported Rule of type "machine_learning" supplied to getFilter'); + }); + test('it works with references and does not add indexes', () => { const esQuery = getQueryFilter( '(event.module:suricata and event.kind:alert) and suricata.eve.alert.signature_id: (2610182 or 2610183 or 2610184 or 2610185 or 2610186 or 2610187)', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts index a12778d5b8f16..4f1a187a82937 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -5,7 +5,8 @@ */ import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { Logger } from '../../../../../../../../src/core/server'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 89dcd3274ebed..03d48a6b27867 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -5,13 +5,17 @@ */ import { Logger } from 'src/core/server'; -import { SIGNALS_ID, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants'; +import { + SIGNALS_ID, + DEFAULT_SEARCH_AFTER_PAGE_SIZE, + NOTIFICATION_THROTTLE_RULE, +} from '../../../../common/constants'; import { buildEventsSearchQuery } from './build_events_query'; import { getInputIndex } from './get_input_output_index'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { getFilter } from './get_filter'; -import { SignalRuleAlertTypeDefinition, AlertAttributes } from './types'; +import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; import { getGapBetweenRuns } from './utils'; import { writeSignalRuleExceptionToSavedObject } from './write_signal_rule_exception_to_saved_object'; import { signalParamsSchema } from './signal_params_schema'; @@ -22,6 +26,8 @@ import { getCurrentStatusSavedObject } from './get_current_status_saved_object'; import { writeCurrentStatusSucceeded } from './write_current_status_succeeded'; import { findMlSignals } from './find_ml_signals'; import { bulkCreateMlSignals } from './bulk_create_ml_signals'; +import { getSignalsCount } from '../notifications/get_signals_count'; +import { scheduleNotificationActions } from '../notifications/schedule_notification_actions'; export const signalRulesAlertType = ({ logger, @@ -46,6 +52,7 @@ export const signalRulesAlertType = ({ index, filters, language, + meta, machineLearningJobId, outputIndex, savedId, @@ -53,7 +60,10 @@ export const signalRulesAlertType = ({ to, type, } = params; - const savedObject = await services.savedObjectsClient.get('alert', alertId); + const savedObject = await services.savedObjectsClient.get( + 'alert', + alertId + ); const ruleStatusSavedObjects = await getRuleStatusSavedObjects({ alertId, @@ -76,6 +86,7 @@ export const signalRulesAlertType = ({ enabled, schedule: { interval }, throttle, + params: ruleParams, } = savedObject.attributes; const updatedAt = savedObject.updated_at ?? ''; @@ -199,6 +210,36 @@ export const signalRulesAlertType = ({ } if (creationSucceeded) { + if (meta?.throttle === NOTIFICATION_THROTTLE_RULE && actions.length) { + const notificationRuleParams = { + ...ruleParams, + name, + }; + const { signalsCount, resultsLink } = await getSignalsCount({ + from: `now-${interval}`, + to: 'now', + index: ruleParams.outputIndex, + ruleId: ruleParams.ruleId!, + kibanaUrl: meta?.kibanaUrl as string, + ruleAlertId: savedObject.id, + callCluster: services.callCluster, + }); + + logger.info( + `Found ${signalsCount} signals using signal rule name: "${notificationRuleParams.name}", id: "${notificationRuleParams.ruleId}", rule_id: "${notificationRuleParams.ruleId}" in "${notificationRuleParams.outputIndex}" index` + ); + + if (signalsCount) { + const alertInstance = services.alertInstanceFactory(alertId); + scheduleNotificationActions({ + alertInstance, + signalsCount, + resultsLink, + ruleParams: notificationRuleParams, + }); + } + } + logger.debug( `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", output_index: "${outputIndex}"` ); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts index 333a938e09d45..e2e4471f609ac 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts @@ -8,7 +8,8 @@ import { countBy, isEmpty } from 'lodash'; import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { SignalSearchResponse, BulkResponse } from './types'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { generateId } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { Logger } from '../../../../../../../../src/core/server'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts index 06acff825f68e..93c48ed38c7c4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RuleAlertParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleAlertParams, OutputRuleAlertRest } from '../types'; import { SearchResponse } from '../../types'; import { AlertType, @@ -159,3 +160,7 @@ export interface AlertAttributes { }; throttle: string | null; } + +export interface RuleAlertAttributes extends AlertAttributes { + params: RuleAlertParams; +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index 2cbdc7db3ba64..aae8763a7ea39 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertAction } from '../../../../../../plugins/alerting/common'; import { CallAPIOptions } from '../../../../../../../src/core/server'; import { Filter } from '../../../../../../../src/plugins/data/server'; import { IRuleStatusAttributes } from './rules/types'; import { ListsDefaultArraySchema } from './routes/schemas/types/lists_default_array'; +import { RuleAlertAction } from '../../../common/detection_engine/types'; export type PartialFilter = Partial; @@ -24,10 +24,6 @@ export interface ThreatParams { technique: IMitreAttack[]; } -export type RuleAlertAction = Omit & { - action_type_id: string; -}; - // Notice below we are using lists: ListsDefaultArraySchema[]; which is coming directly from the response output section. // TODO: Eventually this whole RuleAlertParams will be replaced with io-ts. For now we can slowly strangle it out and reduce duplicate types // We don't have the input types defined through io-ts just yet but as we being introducing types from there we will more and more remove @@ -56,7 +52,7 @@ export interface RuleAlertParams { query: string | undefined | null; references: string[]; savedId?: string | undefined | null; - meta: Record | undefined | null; + meta: Record | undefined | null; severity: string; tags: string[]; to: string; @@ -123,6 +119,7 @@ export type OutputRuleAlertRest = RuleAlertParamsRest & { created_by: string | undefined | null; updated_by: string | undefined | null; immutable: boolean; + throttle: string | undefined | null; }; export type ImportRuleAlertRest = Omit & { diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index c505edc79bc76..7008872a6f3cd 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -27,6 +27,8 @@ import { compose } from './lib/compose/kibana'; import { initRoutes } from './routes'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; +import { rulesNotificationAlertType } from './lib/detection_engine/notifications/rules_notification_alert_type'; +import { isNotificationAlertExecutor } from './lib/detection_engine/notifications/types'; import { noteSavedObjectType, pinnedEventSavedObjectType, @@ -151,12 +153,20 @@ export class Plugin { }); if (plugins.alerting != null) { - const type = signalRulesAlertType({ + const signalRuleType = signalRulesAlertType({ logger: this.logger, version: this.context.env.packageInfo.version, }); - if (isAlertExecutor(type)) { - plugins.alerting.registerType(type); + const ruleNotificationType = rulesNotificationAlertType({ + logger: this.logger, + }); + + if (isAlertExecutor(signalRuleType)) { + plugins.alerting.registerType(signalRuleType); + } + + if (isNotificationAlertExecutor(ruleNotificationType)) { + plugins.alerting.registerType(ruleNotificationType); } } From 6bc866a9b11ceae5c04d17cdb360232ccb29e354 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 24 Mar 2020 16:30:16 +0100 Subject: [PATCH 06/12] introduce StartServicesAccessor type for `CoreSetup.getStartServices` (#60748) (#61070) * create StartServicesAccessor type * update generated doc * update usages to use new type * add missing public annotation --- .../kibana-plugin-core-public.app.mount.md | 2 +- ...ana-plugin-core-public.applicationsetup.md | 2 +- ...c.applicationsetup.registermountcontext.md | 2 +- ...ana-plugin-core-public.applicationstart.md | 2 +- ...c.applicationstart.registermountcontext.md | 2 +- ...bana-plugin-core-public.appmountcontext.md | 2 +- ...a-plugin-core-public.appmountdeprecated.md | 2 +- ...-core-public.coresetup.getstartservices.md | 10 +++------ .../kibana-plugin-core-public.coresetup.md | 7 +------ .../core/public/kibana-plugin-core-public.md | 3 ++- ...lugin-core-public.startservicesaccessor.md | 13 ++++++++++++ ...-core-server.coresetup.getstartservices.md | 10 +++------ .../kibana-plugin-core-server.coresetup.md | 7 +------ .../core/server/kibana-plugin-core-server.md | 1 + ...lugin-core-server.startservicesaccessor.md | 13 ++++++++++++ .../kibana-plugin-plugins-data-server.md | 2 ++ src/core/public/index.ts | 20 +++++++++++------- src/core/public/public.api.md | 6 +++++- src/core/server/index.ts | 21 ++++++++++++------- src/core/server/server.api.md | 6 +++++- .../public/management_app/index.tsx | 4 ++-- .../data/server/kql_telemetry/route.ts | 4 ++-- .../management/public/management_app.tsx | 4 ++-- .../management/public/management_section.ts | 6 +++--- .../management/public/management_service.ts | 6 +++--- .../server/saved_objects/index.ts | 11 +++++++--- .../account_management_app.ts | 6 +++--- .../authentication/authentication_service.ts | 4 ++-- .../logged_out/logged_out_app.ts | 11 +++++++--- .../public/authentication/login/login_app.ts | 11 +++++++--- .../overwritten_session_app.ts | 6 +++--- .../api_keys/api_keys_management_app.tsx | 4 ++-- .../public/management/management_service.ts | 4 ++-- .../role_mappings_management_app.tsx | 4 ++-- .../management/roles/roles_management_app.tsx | 4 ++-- .../management/users/users_management_app.tsx | 4 ++-- .../public/management/management_service.tsx | 4 ++-- .../management/spaces_management_app.tsx | 4 ++-- .../space_selector/space_selector_app.tsx | 6 +++--- 39 files changed, 144 insertions(+), 96 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md diff --git a/docs/development/core/public/kibana-plugin-core-public.app.mount.md b/docs/development/core/public/kibana-plugin-core-public.app.mount.md index c42f73ced95af..8a9dfd9e2e972 100644 --- a/docs/development/core/public/kibana-plugin-core-public.app.mount.md +++ b/docs/development/core/public/kibana-plugin-core-public.app.mount.md @@ -14,5 +14,5 @@ mount: AppMount | AppMountDeprecated ## Remarks -When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md index e5554be515077..fc99e2208220f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md @@ -17,5 +17,5 @@ export interface ApplicationSetup | --- | --- | | [register(app)](./kibana-plugin-core-public.applicationsetup.register.md) | Register an mountable application to the system. | | [registerAppUpdater(appUpdater$)](./kibana-plugin-core-public.applicationsetup.registerappupdater.md) | Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-core-public.appupdatablefields.md) fields of all applications at runtime.This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the updater$ property of the registered application instead. | -| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md index 92a7ae1c0deee..1735d5df943ae 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md @@ -8,7 +8,7 @@ > > -Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md index 834411de5d57c..a93bc61bac527 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md @@ -24,5 +24,5 @@ export interface ApplicationStart | --- | --- | | [getUrlForApp(appId, options)](./kibana-plugin-core-public.applicationstart.geturlforapp.md) | Returns an URL to a given app, including the global base path. By default, the URL is relative (/basePath/app/my-app). Use the absolute option to generate an absolute url (http://host:port/basePath/app/my-app)Note that when generating absolute urls, the protocol, host and port are determined from the browser location. | | [navigateToApp(appId, options)](./kibana-plugin-core-public.applicationstart.navigatetoapp.md) | Navigate to a given app | -| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md index 6e0fbb46e9a1e..11f661c4af2b3 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md @@ -8,7 +8,7 @@ > > -Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md b/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md index d0b243859aab0..52a36b0b56f02 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md @@ -8,7 +8,7 @@ > > -The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md b/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md index 130689882495a..66b8a69d84a38 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md @@ -18,5 +18,5 @@ export declare type AppMountDeprecated = (contex ## Remarks -When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md index 91b906cf83d01..e4fec4eae31b1 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md @@ -2,16 +2,12 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreSetup](./kibana-plugin-core-public.coresetup.md) > [getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md) -## CoreSetup.getStartServices() method +## CoreSetup.getStartServices property -Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed `start`. +[StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) Signature: ```typescript -getStartServices(): Promise<[CoreStart, TPluginsStart]>; +getStartServices: StartServicesAccessor; ``` -Returns: - -`Promise<[CoreStart, TPluginsStart]>` - diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.md index f211b740e84a3..c039bc19348cc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.md @@ -19,14 +19,9 @@ export interface CoreSetup | [application](./kibana-plugin-core-public.coresetup.application.md) | ApplicationSetup | [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | [context](./kibana-plugin-core-public.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-core-public.contextsetup.md) | | [fatalErrors](./kibana-plugin-core-public.coresetup.fatalerrors.md) | FatalErrorsSetup | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | +| [getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart> | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | | [http](./kibana-plugin-core-public.coresetup.http.md) | HttpSetup | [HttpSetup](./kibana-plugin-core-public.httpsetup.md) | | [injectedMetadata](./kibana-plugin-core-public.coresetup.injectedmetadata.md) | {
getInjectedVar: (name: string, defaultValue?: any) => unknown;
} | exposed temporarily until https://github.com/elastic/kibana/issues/41990 done use \*only\* to retrieve config values. There is no way to set injected values in the new platform. Use the legacy platform API instead. | | [notifications](./kibana-plugin-core-public.coresetup.notifications.md) | NotificationsSetup | [NotificationsSetup](./kibana-plugin-core-public.notificationssetup.md) | | [uiSettings](./kibana-plugin-core-public.coresetup.uisettings.md) | IUiSettingsClient | [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) | -## Methods - -| Method | Description | -| --- | --- | -| [getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | - diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index b8aa56eb2941b..adc87de2b9e7e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -38,7 +38,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AppLeaveDefaultAction](./kibana-plugin-core-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-core-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | | [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | | [ApplicationStart](./kibana-plugin-core-public.applicationstart.md) | | -| [AppMountContext](./kibana-plugin-core-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [AppMountContext](./kibana-plugin-core-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | | [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) | | | [Capabilities](./kibana-plugin-core-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | | [ChromeBadge](./kibana-plugin-core-public.chromebadge.md) | | @@ -153,6 +153,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | | [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | | [SavedObjectsClientContract](./kibana-plugin-core-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) | +| [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | | [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string | | [Toast](./kibana-plugin-core-public.toast.md) | | | [ToastInput](./kibana-plugin-core-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | diff --git a/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md b/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md new file mode 100644 index 0000000000000..02e896a6b47e5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) + +## StartServicesAccessor type + +Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed `start`. + +Signature: + +```typescript +export declare type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md index 10a656363c0d0..ea8e610ee56de 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md @@ -2,16 +2,12 @@ [Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreSetup](./kibana-plugin-core-server.coresetup.md) > [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) -## CoreSetup.getStartServices() method +## CoreSetup.getStartServices property -Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed `start`. This should only be used inside handlers registered during `setup` that will only be executed after `start` lifecycle. +[StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) Signature: ```typescript -getStartServices(): Promise<[CoreStart, TPluginsStart]>; +getStartServices: StartServicesAccessor; ``` -Returns: - -`Promise<[CoreStart, TPluginsStart]>` - diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.md index 5b5803629cc86..b0eba8ac78063 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -19,15 +19,10 @@ export interface CoreSetup | [capabilities](./kibana-plugin-core-server.coresetup.capabilities.md) | CapabilitiesSetup | [CapabilitiesSetup](./kibana-plugin-core-server.capabilitiessetup.md) | | [context](./kibana-plugin-core-server.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-core-server.contextsetup.md) | | [elasticsearch](./kibana-plugin-core-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup | [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) | +| [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart> | [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | | [http](./kibana-plugin-core-server.coresetup.http.md) | HttpServiceSetup | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | | [metrics](./kibana-plugin-core-server.coresetup.metrics.md) | MetricsServiceSetup | [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | | [savedObjects](./kibana-plugin-core-server.coresetup.savedobjects.md) | SavedObjectsServiceSetup | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | | [uiSettings](./kibana-plugin-core-server.coresetup.uisettings.md) | UiSettingsServiceSetup | [UiSettingsServiceSetup](./kibana-plugin-core-server.uisettingsservicesetup.md) | | [uuid](./kibana-plugin-core-server.coresetup.uuid.md) | UuidServiceSetup | [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) | -## Methods - -| Method | Description | -| --- | --- | -| [getStartServices()](./kibana-plugin-core-server.coresetup.getstartservices.md) | Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed start. This should only be used inside handlers registered during setup that will only be executed after start lifecycle. | - diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 54cf496b2d6af..a1158dc853918 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -259,6 +259,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [SharedGlobalConfig](./kibana-plugin-core-server.sharedglobalconfig.md) | | +| [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed start. This should only be used inside handlers registered during setup that will only be executed after start lifecycle. | | [StringValidation](./kibana-plugin-core-server.stringvalidation.md) | Allows regex objects or a regex string | | [UiSettingsType](./kibana-plugin-core-server.uisettingstype.md) | UI element type to represent the settings. | diff --git a/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md b/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md new file mode 100644 index 0000000000000..4de781fc99cc1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) + +## StartServicesAccessor type + +Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed `start`. This should only be used inside handlers registered during `setup` that will only be executed after `start` lifecycle. + +Signature: + +```typescript +export declare type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index d179b9d9dcd82..e756eb9b72905 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -60,6 +60,7 @@ | [esQuery](./kibana-plugin-plugins-data-server.esquery.md) | | | [fieldFormats](./kibana-plugin-plugins-data-server.fieldformats.md) | | | [indexPatterns](./kibana-plugin-plugins-data-server.indexpatterns.md) | | +| [search](./kibana-plugin-plugins-data-server.search.md) | | ## Type Aliases @@ -69,5 +70,6 @@ | [IFieldFormatsRegistry](./kibana-plugin-plugins-data-server.ifieldformatsregistry.md) | | | [ISearch](./kibana-plugin-plugins-data-server.isearch.md) | | | [ISearchCancel](./kibana-plugin-plugins-data-server.isearchcancel.md) | | +| [ParsedInterval](./kibana-plugin-plugins-data-server.parsedinterval.md) | | | [TSearchStrategyProvider](./kibana-plugin-plugins-data-server.tsearchstrategyprovider.md) | Search strategy provider creates an instance of a search strategy with the request handler context bound to it. This way every search strategy can use whatever information they require from the request context. | diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 360b254ca50c1..00fd790bf4cc2 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -209,15 +209,21 @@ export interface CoreSetup { injectedMetadata: { getInjectedVar: (name: string, defaultValue?: any) => unknown; }; - - /** - * Allows plugins to get access to APIs available in start inside async - * handlers, such as {@link App.mount}. Promise will not resolve until Core - * and plugin dependencies have completed `start`. - */ - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + /** {@link StartServicesAccessor} */ + getStartServices: StartServicesAccessor; } +/** + * Allows plugins to get access to APIs available in start inside async + * handlers, such as {@link App.mount}. Promise will not resolve until Core + * and plugin dependencies have completed `start`. + * + * @public + */ +export type StartServicesAccessor = () => Promise< + [CoreStart, TPluginsStart] +>; + /** * Core services exposed to the `Plugin` start lifecycle * diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 37212a07ee631..eec12f2348176 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -378,7 +378,8 @@ export interface CoreSetup { context: ContextSetup; // (undocumented) fatalErrors: FatalErrorsSetup; - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + // (undocumented) + getStartServices: StartServicesAccessor; // (undocumented) http: HttpSetup; // @deprecated @@ -1235,6 +1236,9 @@ export class SimpleSavedObject { _version?: SavedObject['version']; } +// @public +export type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; + // @public export type StringValidation = StringValidationRegex | StringValidationRegexString; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 89fee92a7ef02..1b436bfd72622 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -352,15 +352,22 @@ export interface CoreSetup { uuid: UuidServiceSetup; /** {@link MetricsServiceSetup} */ metrics: MetricsServiceSetup; - /** - * Allows plugins to get access to APIs available in start inside async handlers. - * Promise will not resolve until Core and plugin dependencies have completed `start`. - * This should only be used inside handlers registered during `setup` that will only be executed - * after `start` lifecycle. - */ - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + /** {@link StartServicesAccessor} */ + getStartServices: StartServicesAccessor; } +/** + * Allows plugins to get access to APIs available in start inside async handlers. + * Promise will not resolve until Core and plugin dependencies have completed `start`. + * This should only be used inside handlers registered during `setup` that will only be executed + * after `start` lifecycle. + * + * @public + */ +export type StartServicesAccessor = () => Promise< + [CoreStart, TPluginsStart] +>; + /** * Context passed to the plugins `start` method. * diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 229ffc4d21575..6d4181e5e1ab3 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -629,7 +629,8 @@ export interface CoreSetup { context: ContextSetup; // (undocumented) elasticsearch: ElasticsearchServiceSetup; - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + // (undocumented) + getStartServices: StartServicesAccessor; // (undocumented) http: HttpServiceSetup; // (undocumented) @@ -2269,6 +2270,9 @@ export type SharedGlobalConfig = RecursiveReadonly_2<{ path: Pick; }>; +// @public +export type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; + // @public export type StringValidation = StringValidationRegex | StringValidationRegexString; diff --git a/src/plugins/advanced_settings/public/management_app/index.tsx b/src/plugins/advanced_settings/public/management_app/index.tsx index 27d3114051c16..53b8f9983aa27 100644 --- a/src/plugins/advanced_settings/public/management_app/index.tsx +++ b/src/plugins/advanced_settings/public/management_app/index.tsx @@ -24,7 +24,7 @@ import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; import { AdvancedSettings } from './advanced_settings'; import { ManagementSetup } from '../../../management/public'; -import { CoreSetup } from '../../../../core/public'; +import { StartServicesAccessor } from '../../../../core/public'; import { ComponentRegistry } from '../types'; const title = i18n.translate('advancedSettings.advancedSettingsLabel', { @@ -48,7 +48,7 @@ export async function registerAdvSettingsMgmntApp({ componentRegistry, }: { management: ManagementSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; componentRegistry: ComponentRegistry['start']; }) { const kibanaSection = management.sections.getSection('kibana'); diff --git a/src/plugins/data/server/kql_telemetry/route.ts b/src/plugins/data/server/kql_telemetry/route.ts index d5725c859c9a9..dd7ff333e6257 100644 --- a/src/plugins/data/server/kql_telemetry/route.ts +++ b/src/plugins/data/server/kql_telemetry/route.ts @@ -17,12 +17,12 @@ * under the License. */ -import { CoreSetup, IRouter, Logger } from 'kibana/server'; +import { StartServicesAccessor, IRouter, Logger } from 'kibana/server'; import { schema } from '@kbn/config-schema'; export function registerKqlTelemetryRoute( router: IRouter, - getStartServices: CoreSetup['getStartServices'], + getStartServices: StartServicesAccessor, logger: Logger ) { router.post( diff --git a/src/plugins/management/public/management_app.tsx b/src/plugins/management/public/management_app.tsx index 705d98eaaf2ff..38db1039042e5 100644 --- a/src/plugins/management/public/management_app.tsx +++ b/src/plugins/management/public/management_app.tsx @@ -26,7 +26,7 @@ import { KibanaLegacySetup } from '../../kibana_legacy/public'; import { LegacyManagementSection } from './legacy'; import { ManagementChrome } from './components'; import { ManagementSection } from './management_section'; -import { ChromeBreadcrumb, CoreSetup } from '../../../core/public/'; +import { ChromeBreadcrumb, StartServicesAccessor } from '../../../core/public/'; export class ManagementApp { readonly id: string; @@ -41,7 +41,7 @@ export class ManagementApp { getSections: () => ManagementSection[], registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagementSections: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { this.id = id; this.title = title; diff --git a/src/plugins/management/public/management_section.ts b/src/plugins/management/public/management_section.ts index 2f323c4b6a9cf..483605341ae4c 100644 --- a/src/plugins/management/public/management_section.ts +++ b/src/plugins/management/public/management_section.ts @@ -19,7 +19,7 @@ import { CreateSection, RegisterManagementAppArgs } from './types'; import { KibanaLegacySetup } from '../../kibana_legacy/public'; -import { CoreSetup } from '../../../core/public'; +import { StartServicesAccessor } from '../../../core/public'; // @ts-ignore import { LegacyManagementSection } from './legacy'; import { ManagementApp } from './management_app'; @@ -34,14 +34,14 @@ export class ManagementSection { private readonly getSections: () => ManagementSection[]; private readonly registerLegacyApp: KibanaLegacySetup['registerLegacyApp']; private readonly getLegacyManagementSection: () => LegacyManagementSection; - private readonly getStartServices: CoreSetup['getStartServices']; + private readonly getStartServices: StartServicesAccessor; constructor( { id, title, order = 100, euiIconType, icon }: CreateSection, getSections: () => ManagementSection[], registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagementSection: () => ManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { this.id = id; this.title = title; diff --git a/src/plugins/management/public/management_service.ts b/src/plugins/management/public/management_service.ts index 4a900345b3843..ed31a22992da8 100644 --- a/src/plugins/management/public/management_service.ts +++ b/src/plugins/management/public/management_service.ts @@ -22,7 +22,7 @@ import { KibanaLegacySetup } from '../../kibana_legacy/public'; // @ts-ignore import { LegacyManagementSection } from './legacy'; import { CreateSection } from './types'; -import { CoreSetup, CoreStart } from '../../../core/public'; +import { StartServicesAccessor, CoreStart } from '../../../core/public'; export class ManagementService { private sections: ManagementSection[] = []; @@ -30,7 +30,7 @@ export class ManagementService { private register( registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagement: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { return (section: CreateSection) => { if (this.getSection(section.id)) { @@ -71,7 +71,7 @@ export class ManagementService { public setup( kibanaLegacy: KibanaLegacySetup, getLegacyManagement: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { const register = this.register.bind(this)( kibanaLegacy.registerLegacyApp, diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index 0dbdc2f3ac7e3..c76477cd8da43 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, SavedObject, SavedObjectsBaseOptions } from 'src/core/server'; +import { + StartServicesAccessor, + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsServiceSetup, +} from 'src/core/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; interface SetupSavedObjectsParams { service: PublicMethodsOf; - savedObjects: CoreSetup['savedObjects']; - getStartServices: CoreSetup['getStartServices']; + savedObjects: SavedObjectsServiceSetup; + getStartServices: StartServicesAccessor; } export interface SavedObjectsSetup { diff --git a/x-pack/plugins/security/public/account_management/account_management_app.ts b/x-pack/plugins/security/public/account_management/account_management_app.ts index 8a14a772a1eef..cd3ef34858b19 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.ts +++ b/x-pack/plugins/security/public/account_management/account_management_app.ts @@ -5,14 +5,14 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { StartServicesAccessor, ApplicationSetup, AppMountParameters } from 'src/core/public'; import { AuthenticationServiceSetup } from '../authentication'; import { UserAPIClient } from '../management'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; authc: AuthenticationServiceSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const accountManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/authentication/authentication_service.ts b/x-pack/plugins/security/public/authentication/authentication_service.ts index 7b88b0f8573ba..979f7095cf933 100644 --- a/x-pack/plugins/security/public/authentication/authentication_service.ts +++ b/x-pack/plugins/security/public/authentication/authentication_service.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ApplicationSetup, CoreSetup, HttpSetup } from 'src/core/public'; +import { ApplicationSetup, StartServicesAccessor, HttpSetup } from 'src/core/public'; import { AuthenticatedUser } from '../../common/model'; import { ConfigType } from '../config'; import { PluginStartDependencies } from '../plugin'; @@ -17,7 +17,7 @@ interface SetupParams { application: ApplicationSetup; config: ConfigType; http: HttpSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export interface AuthenticationServiceSetup { diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts index b7f2615318791..2849111e7efeb 100644 --- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts @@ -5,12 +5,17 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters, HttpSetup } from 'src/core/public'; +import { + StartServicesAccessor, + ApplicationSetup, + AppMountParameters, + HttpSetup, +} from 'src/core/public'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; http: HttpSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const loggedOutApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/authentication/login/login_app.ts b/x-pack/plugins/security/public/authentication/login/login_app.ts index 1642aba51c1ae..1ecb5dcfd7990 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.ts @@ -5,13 +5,18 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters, HttpSetup } from 'src/core/public'; +import { + StartServicesAccessor, + AppMountParameters, + ApplicationSetup, + HttpSetup, +} from 'src/core/public'; import { ConfigType } from '../../config'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; http: HttpSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; config: Pick; } diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts index 1bbe388a635e2..8e0ee73dfb613 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts @@ -5,13 +5,13 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { StartServicesAccessor, ApplicationSetup, AppMountParameters } from 'src/core/public'; import { AuthenticationServiceSetup } from '../authentication_service'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; authc: AuthenticationServiceSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const overwrittenSessionApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx index 35de732b84ce9..272fc9cfc2fe6 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { PluginStartDependencies } from '../../plugin'; import { APIKeysGridPage } from './api_keys_grid'; @@ -15,7 +15,7 @@ import { APIKeysAPIClient } from './api_keys_api_client'; import { DocumentationLinksService } from './documentation_links'; interface CreateParams { - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const apiKeysManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/management/management_service.ts b/x-pack/plugins/security/public/management/management_service.ts index 5ad3681590fbf..7c4c470730ffe 100644 --- a/x-pack/plugins/security/public/management/management_service.ts +++ b/x-pack/plugins/security/public/management/management_service.ts @@ -6,7 +6,7 @@ import { Subscription } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import { CoreSetup, FatalErrorsSetup } from 'src/core/public'; +import { StartServicesAccessor, FatalErrorsSetup } from 'src/core/public'; import { ManagementApp, ManagementSetup, @@ -25,7 +25,7 @@ interface SetupParams { license: SecurityLicense; authc: AuthenticationServiceSetup; fatalErrors: FatalErrorsSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } interface StartParams { diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx index 8e1ac8d7f6957..ea090520fdd46 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { PluginStartDependencies } from '../../plugin'; import { RolesAPIClient } from '../roles'; @@ -18,7 +18,7 @@ import { RoleMappingsGridPage } from './role_mappings_grid'; import { EditRoleMappingPage } from './edit_role_mapping'; interface CreateParams { - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const roleMappingsManagementApp = Object.freeze({ 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 4e8c95b61c2f1..4265cac22ece0 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 @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup, FatalErrorsSetup } from 'src/core/public'; +import { StartServicesAccessor, FatalErrorsSetup } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { SecurityLicense } from '../../../common/licensing'; import { PluginStartDependencies } from '../../plugin'; @@ -23,7 +23,7 @@ import { PrivilegesAPIClient } from './privileges_api_client'; interface CreateParams { fatalErrors: FatalErrorsSetup; license: SecurityLicense; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const rolesManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/management/users/users_management_app.tsx b/x-pack/plugins/security/public/management/users/users_management_app.tsx index 7874b810676b5..82a2b8d2a98ad 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { AuthenticationServiceSetup } from '../../authentication'; import { PluginStartDependencies } from '../../plugin'; @@ -19,7 +19,7 @@ import { EditUserPage } from './edit_user'; interface CreateParams { authc: AuthenticationServiceSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const usersManagementApp = Object.freeze({ diff --git a/x-pack/plugins/spaces/public/management/management_service.tsx b/x-pack/plugins/spaces/public/management/management_service.tsx index c81a3497762a5..cec4bee1373ca 100644 --- a/x-pack/plugins/spaces/public/management/management_service.tsx +++ b/x-pack/plugins/spaces/public/management/management_service.tsx @@ -5,7 +5,7 @@ */ import { ManagementSetup, ManagementApp } from 'src/plugins/management/public'; -import { CoreSetup, Capabilities } from 'src/core/public'; +import { StartServicesAccessor, Capabilities } from 'src/core/public'; import { SecurityLicense } from '../../../security/public'; import { SpacesManager } from '../spaces_manager'; import { PluginsStart } from '../plugin'; @@ -13,7 +13,7 @@ import { spacesManagementApp } from './spaces_management_app'; interface SetupDeps { management: ManagementSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; spacesManager: SpacesManager; securityLicense?: SecurityLicense; } 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 663237cfc2e8a..2a93e684bb716 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { SecurityLicense } from '../../../security/public'; import { RegisterManagementAppArgs } from '../../../../../src/plugins/management/public'; import { PluginsStart } from '../plugin'; @@ -18,7 +18,7 @@ import { ManageSpacePage } from './edit_space'; import { Space } from '..'; interface CreateParams { - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; spacesManager: SpacesManager; securityLicense?: SecurityLicense; } diff --git a/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx b/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx index 6fab1767e4b6d..048f0e30cd469 100644 --- a/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx +++ b/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { StartServicesAccessor, ApplicationSetup, AppMountParameters } from 'src/core/public'; import { i18n } from '@kbn/i18n'; import { SpacesManager } from '../spaces_manager'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; spacesManager: SpacesManager; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const spaceSelectorApp = Object.freeze({ From 2221332caed48b4621eaed9b9f7a57fe84acc029 Mon Sep 17 00:00:00 2001 From: kqualters-elastic <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 24 Mar 2020 12:40:53 -0400 Subject: [PATCH 07/12] [7.x] Flatten child api response for resolver (#60810) (#61078) --- .../embeddables/resolver/store/middleware.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts index 23e4a4fe7d7ed..4e57212e5c0c2 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts @@ -16,6 +16,19 @@ type MiddlewareFactory = ( ) => ( api: MiddlewareAPI, S> ) => (next: Dispatch) => (action: ResolverAction) => unknown; +interface Lifecycle { + lifecycle: ResolverEvent[]; +} +type ChildResponse = [Lifecycle]; + +function flattenEvents(events: ChildResponse): ResolverEvent[] { + return events + .map((child: Lifecycle) => child.lifecycle) + .reduce( + (accumulator: ResolverEvent[], value: ResolverEvent[]) => accumulator.concat(value), + [] + ); +} export const resolverMiddlewareFactory: MiddlewareFactory = context => { return api => next => async (action: ResolverAction) => { @@ -47,7 +60,7 @@ export const resolverMiddlewareFactory: MiddlewareFactory = context => { query: { legacyEndpointID }, }), ]); - childEvents = children.length > 0 ? children.map((child: any) => child.lifecycle) : []; + childEvents = children.length > 0 ? flattenEvents(children) : []; } else { const uniquePid = action.payload.selectedEvent.process.entity_id; const ppid = action.payload.selectedEvent.process.parent?.entity_id; @@ -67,7 +80,7 @@ export const resolverMiddlewareFactory: MiddlewareFactory = context => { getAncestors(ppid), ]); } - childEvents = children.length > 0 ? children.map((child: any) => child.lifecycle) : []; + childEvents = children.length > 0 ? flattenEvents(children) : []; response = [...lifecycle, ...childEvents, ...relatedEvents, ...ancestors]; api.dispatch({ type: 'serverReturnedResolverData', From 962ca0764a6e6869a448ced64e89c1766fca08f6 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 24 Mar 2020 18:01:21 +0100 Subject: [PATCH 08/12] do not warn when switching capabilities for resources with optional auth (#61043) (#61090) * do not switch capabilities for optional routes * downgrade message to debug --- .../spaces/server/capabilities/capabilities_switcher.test.ts | 4 ++-- .../spaces/server/capabilities/capabilities_switcher.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) 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 3f7b93c754aef..fcd756c2aca10 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -154,7 +154,7 @@ describe('capabilitiesSwitcher', () => { expect(result).toEqual(buildCapabilities()); }); - it('logs a warning, and does not toggle capabilities if an error is encountered', async () => { + it('logs a debug message, and does not toggle capabilities if an error is encountered', async () => { const space: Space = { id: 'space', name: '', @@ -171,7 +171,7 @@ describe('capabilitiesSwitcher', () => { const result = await switcher(request, capabilities); expect(result).toEqual(buildCapabilities()); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.debug).toHaveBeenCalledWith( `Error toggling capabilities for request to /path: Error: Something terrible happened` ); }); diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts index 317cc7fe0e3c3..ddbea91f7268c 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts @@ -30,9 +30,10 @@ export function setupCapabilitiesSwitcher( const registeredFeatures = features.getFeatures(); + // try to retrieve capabilities for authenticated or "maybe authenticated" users return toggleCapabilities(registeredFeatures, capabilities, activeSpace); } catch (e) { - logger.warn(`Error toggling capabilities for request to ${request.url.pathname}: ${e}`); + logger.debug(`Error toggling capabilities for request to ${request.url.pathname}: ${e}`); return capabilities; } }; From 98bc533fb9944271dce98965c86e0c6c9e5ea99a Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 24 Mar 2020 13:07:59 -0400 Subject: [PATCH 09/12] [7.x] Support for sub-feature privileges (#60563) (#61089) * 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 f1e1f20de1ce6..0c6686c993371 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 502e910caae51..d1f7ce325d23e 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 5122796335e45..53d32a836cfa1 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 02e38ff54b300..5b52a3eba2f23 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 7008872a6f3cd..d785de32eab7e 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 a9abc733775d2..7b5dc19760627 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 bfda7ef5885bc..0325de9cf29e2 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 aef85f39e0382..4b4afd8088744 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 748076b95ad77..82fcc33f5c8ce 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 1d14f3728282c..768c8c6ae1088 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 6111d7d25a61b..e359efbda20d2 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 0000000000000..121bb8514c8a2 --- /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 553e920f0e720..e38d7be892904 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 0000000000000..e3a25ad57425c --- /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 0000000000000..b93c9bf917d79 --- /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 6a2c99aad4bd8..f19c7f947d97f 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 0000000000000..014883f3ce9cf --- /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 0000000000000..aab712d647508 --- /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 0000000000000..c168384dae78f --- /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 0000000000000..ee94d0d40b853 --- /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 7b25035892668..5b4f7728c9f31 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 60a229fc58612..73a353cd27471 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 cc12ea1b78dce..fdeceb30b4e3d 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 48ef97a494f7e..48a350ae8f8fd 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 987af08fe7cda..72beff02173d2 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 b48963ebb8139..3e8ce37fd1578 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 e77fa218c0681..cebf67243fb28 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 b0f8417b7175d..c43e2a5195fe7 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 cf4d61ccac88b..428500c3daa88 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 bb2cd82891a15..73c399878b17b 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 a13afa854de52..d3d3230822749 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 edf94beab43a7..5301e1e9cbd0b 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 67737c6fe502e..45c847fe1f68a 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 8948d232b9e5e..c4b6028cdbb71 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 2c2039c5e2e92..34bc44b88e40d 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 88da416cf715b..59d4908c67ffb 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 fd4cdf33028eb..0000000000000 --- 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 ffe55b813217f..0000000000000 --- 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 61e5f083a7798..0000000000000 --- 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 5c8b4196a2b55..0000000000000 --- 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 0000000000000..68d352363d363 --- /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 0000000000000..98110a83103aa --- /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 23a3f327a2c5c..f1ee681331005 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 cd7766ef38748..f0d5abf89dd2e 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 3fd8536951967..1fad9057665da 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 617335dc9fb34..a911455f95b5d 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 799ff205e2540..0000000000000 --- 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 c480f33b57899..2083778e53998 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 8283efe23260a..4610da95e9649 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 0000000000000..8897d89a39926 --- /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 0000000000000..fb302c2269485 --- /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 0000000000000..ba7eff601f4c1 --- /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 0000000000000..d4b6721ddad05 --- /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 0000000000000..316818e4deed3 --- /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 0000000000000..9e4a3a8a99b56 --- /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 09e449f61356f..8f084fcc37c50 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 70e48dcdc37f8..0000000000000 --- 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 ddab7eff6835e..0000000000000 --- 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 0c794b68f95da..0000000000000 --- 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 2a1c42838a83d..0000000000000 --- 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 cea25649c43ff..0000000000000 --- 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 8d30061b92c6f..0000000000000 --- 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 9fefea637e168..0000000000000 --- 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 887fffa1b0cbc..0000000000000 --- 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 1ca87871aa892..0000000000000 --- 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 4c44c077f0336..0000000000000 --- 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 c3bf12b6aef5f..0000000000000 --- 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 aeaf12d02210a..0000000000000 --- 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 febdb64b93d61..0000000000000 --- 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 6487179b1d6e5..8fea0e02f3c8d 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 a4e287632c764..284bcb29f9b6e 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 056a4d3022fc5..121d615c1fc35 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 0000000000000..edf2af918fd04 --- /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 0000000000000..8cff37f4bd4b0 --- /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 0000000000000..63b38b6967575 --- /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 ab9baa1356c4b..5f7dc0d99654e 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 0000000000000..85144d37ce754 --- /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 0000000000000..e0889d91d759a --- /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 0000000000000..6163a6ec7ba23 --- /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 0000000000000..27ed8c443045a --- /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 0000000000000..3283f7a58a27c --- /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 0000000000000..0498f099b536b --- /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 0000000000000..e04ca36b6d193 --- /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 0000000000000..b691056528498 --- /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 0000000000000..8ed9bb449b595 --- /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 4d8f590f286ae..7873e47d2e0ff 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 db1e3cfd61621..7ecf32ee45b85 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 2221fc6bab279..d68d43e8089c7 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 428836c9f181b..0000000000000 --- 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 a3fbdebee7eba..0000000000000 --- 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 8d10e27df9694..0000000000000 --- 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 c6268e19abfd1..155ccf98b9762 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 55ac99da4c8c1..93f1d9bba460d 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 a01c026c1a5df..0000000000000 --- 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 f0f425273e25d..0000000000000 --- 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 675f02a81f9e1..968730181fe10 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 6f841b5d14cb3..4e9e02bb531f1 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 f0a391c98c910..b1c7cb4b631e6 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 1a43fb9e2683a..ccb5398a11b23 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 e06d2a4f7dc33..a9bcb5433fcc7 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 a847ccb677485..86b09e5332792 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 1e42a926c51f7..70790f785ad58 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 f8b2991a844f7..92e42ec811afc 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 0000000000000..a1f1e36e8df86 --- /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 0000000000000..d8d75e90847e3 --- /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 0000000000000..9ed460fe734ef --- /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 0000000000000..6b1c3785721b3 --- /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 0000000000000..cbbd22857666e --- /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 0000000000000..7fc466a70b984 --- /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 0000000000000..3d69e5e709bb0 --- /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 0000000000000..e149a59e12edf --- /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 0000000000000..b437649236e27 --- /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 5936409eb6e8b..96051dbd7fa56 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 4265cac22ece0..e1a10fdc2b8c3 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 3d0ef3b2cabc7..122b26378d22b 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 dcd90b1738f10..38ef552e75a9e 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 4bf7a41550cc6..00293e88abe76 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 60a42ba6a78a2..d6e7a5d242d49 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 35e614e7a03d4..60b135acc15ef 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 a696fd8693997..74c372a0699a2 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 ed0854e8a805b..227c658619175 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 5e5da7233d93e..9e8bfb6ad795f 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 4a0bc7cda1b8f..e3a02d3807399 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 f91b7baf78baa..32827822117d0 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 9e77c319a9b3a..3dae9a47b3827 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 49c9db2d0e6e3..912ae60e12065 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 9e99cae620633..3252053454764 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 4cbc76ecb6be4..f065c9cfd90ba 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 c874886d908eb..514d6734b47ba 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 3dbe71db93f4a..fc15aff32b975 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 0180554a47ccc..7a2bb87d72b45 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 0000000000000..7d92eacfe6b35 --- /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 0000000000000..e239a6e280aec --- /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 253dcaed9f19e..24af524c350b0 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 0000000000000..b288262be25c6 --- /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 22b9cd45d4c0f..e12a33ce509bd 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 38d4d413c591e..3d25fc03f568b 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 c73c4be8f36ac..b25aad30a3423 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 3dc3ae03b18cb..ac386d287cff1 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 7998c816ae1c7..510feb1151a9b 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 a23c826b32fbd..4767f57de764c 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 9217d5a437f9c..7751f9a952c09 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 7db3d5456fbd3..6d40ce15fc57f 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 d9282ad0457dd..ca53a9eb17253 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 52a0fe8d4d26c..6f0462a6ddcc2 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 380f151b54a18..880842ed0ae30 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 2aba1522a7e3f..b79bbd0d6ab3f 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 a1b64eb954403..09dbe886ab191 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 d4c6bdaea2776..782c261be9664 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 4cc4190e9591b..ff4be84207832 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 90c7aba65e3d6..9b7dc921b9a25 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 2e274e08ee13b..7738a440cb5e1 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 2a93e684bb716..92b369807b0da 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 44215ec538002..876ab39df3a1f 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 fcd756c2aca10..2c1ab26dd3d82 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 8ada3576c7ba8..0e24772ab9ab1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10451,7 +10451,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": "特定のドキュメントの読み込み権限を提供", @@ -10481,13 +10480,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": "利用可能なすべてのスペースを表示する権限がありません。", @@ -10511,15 +10503,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}", @@ -10546,7 +10532,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 6bf0b4bd6bb69..41a7ee825c7ab 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10451,7 +10451,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": "授予特定文档的读取权限", @@ -10481,13 +10480,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": "您无权查看所有可用工作区。", @@ -10511,15 +10503,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}。", @@ -10546,7 +10532,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 19506bb316a05..da208e13acdad 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 acd14e8a2bf7b..019b15cc1862a 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 9b4a2d14de9ea..fe0f630830a56 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 df35ec2195dc5..ad1876cb717f1 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 0b29fc1cac7de..77293ddff3f9f 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 0000000000000..0b29fc1cac7de --- /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 0000000000000..dcbdb17724249 --- /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 c427bf7fa8f28..d21bfa4d7031a 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 b966d37becc3f..de68ec0c64c17 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 98ab4c1f15a54..dc8c488460100 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 e5b6512d1c1b0..9f080a056e91f 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 6110996a553dc..89ae0125614b6 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'], From 7b5f8a33f09b5265df1e7b16434958c2aef88e5f Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Tue, 24 Mar 2020 13:09:51 -0400 Subject: [PATCH 10/12] [Lens] Fix bug in metric config panel (#60982) (#61081) * [Lens] Fix bug in metric config panel * Fix test --- .../metric_visualization.test.ts | 40 +++++++++++++++++++ .../metric_visualization.tsx | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts index 276f24433c670..62f47a21c85b0 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts @@ -72,6 +72,46 @@ describe('metric_visualization', () => { }); }); + describe('#getConfiguration', () => { + it('can add a metric when there is no accessor', () => { + expect( + metricVisualization.getConfiguration({ + state: { + accessor: undefined, + layerId: 'l1', + }, + layerId: 'l1', + frame: mockFrame(), + }) + ).toEqual({ + groups: [ + expect.objectContaining({ + supportsMoreColumns: true, + }), + ], + }); + }); + + it('is not allowed to add a metric once one accessor is set', () => { + expect( + metricVisualization.getConfiguration({ + state: { + accessor: 'a', + layerId: 'l1', + }, + layerId: 'l1', + frame: mockFrame(), + }) + ).toEqual({ + groups: [ + expect.objectContaining({ + supportsMoreColumns: false, + }), + ], + }); + }); + }); + describe('#setDimension', () => { it('sets the accessor', () => { expect( diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx index 44256df5aed6d..73b8019a31eaa 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx @@ -94,7 +94,7 @@ export const metricVisualization: Visualization = { groupLabel: i18n.translate('xpack.lens.metric.label', { defaultMessage: 'Metric' }), layerId: props.state.layerId, accessors: props.state.accessor ? [props.state.accessor] : [], - supportsMoreColumns: false, + supportsMoreColumns: !props.state.accessor, filterOperations: (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number', }, ], From 37ce3464d4dd57158e4dd708bbb584bb0cd88ed8 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Tue, 24 Mar 2020 10:11:30 -0700 Subject: [PATCH 11/12] Updating our direct usage of https-proxy-agent to 5.0.0 (#58296) (#61086) Co-authored-by: Elastic Machine Co-authored-by: Elastic Machine --- package.json | 2 +- yarn.lock | 40 ++++++++++++++++++++-------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 46f205da0e31f..f156c432ab03e 100644 --- a/package.json +++ b/package.json @@ -187,7 +187,7 @@ "hjson": "3.2.1", "hoek": "^5.0.4", "http-proxy-agent": "^2.1.0", - "https-proxy-agent": "^2.2.2", + "https-proxy-agent": "^5.0.0", "immer": "^1.5.0", "inert": "^5.1.0", "inline-style": "^2.0.0", diff --git a/yarn.lock b/yarn.lock index 19d193ccb3308..060716fe1803f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6708,6 +6708,13 @@ agent-base@4: dependencies: es6-promisify "^5.0.0" +agent-base@6: + version "6.0.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.0.tgz#5d0101f19bbfaed39980b22ae866de153b93f09a" + integrity sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw== + dependencies: + debug "4" + agent-base@^4.1.0: version "4.2.1" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" @@ -6715,13 +6722,6 @@ agent-base@^4.1.0: dependencies: es6-promisify "^5.0.0" -agent-base@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" - integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== - dependencies: - es6-promisify "^5.0.0" - agentkeepalive@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-3.4.1.tgz#aa95aebc3a749bca5ed53e3880a09f5235b48f0c" @@ -11849,6 +11849,13 @@ debug@3.2.6, debug@3.X, debug@^3.1.0, debug@^3.2.5, debug@^3.2.6: dependencies: ms "^2.1.1" +debug@4, debug@4.1.1, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + debug@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.0.tgz#373687bffa678b38b1cd91f861b63850035ddc87" @@ -11856,13 +11863,6 @@ debug@4.1.0: dependencies: ms "^2.1.1" -debug@4.1.1, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== - dependencies: - ms "^2.1.1" - debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -17200,13 +17200,13 @@ https-proxy-agent@2.2.1, https-proxy-agent@^2.2.1: agent-base "^4.1.0" debug "^3.1.0" -https-proxy-agent@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.2.tgz#271ea8e90f836ac9f119daccd39c19ff7dfb0793" - integrity sha512-c8Ndjc9Bkpfx/vCJueCPy0jlP4ccCCSNDp8xwCZzPjKJUm+B+u9WX2x98Qx4n1PiMNTWo3D7KK5ifNV/yJyRzg== +https-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== dependencies: - agent-base "^4.3.0" - debug "^3.1.0" + agent-base "6" + debug "4" human-signals@^1.1.1: version "1.1.1" From 8d39068bc06976bae464873f438c75011c9836dd Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 24 Mar 2020 18:13:55 +0100 Subject: [PATCH 12/12] [Upgrade Assistant] Fix edge case where reindex op can falsely be seen as stale (#60770) (#61082) * Fix edge case where reindex op is can falsely be seen as stale This is for multiple Kibana workers, to ensure that an item just coming off the queue is seen as "new" we set a "startedAt" field which will update the reindex op and give it the full timeout window. * Update tests to use new api too Co-authored-by: Elastic Machine # Conflicts: # x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts # x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts # x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts # x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts --- .../plugins/upgrade_assistant/common/types.ts | 20 +++++++ .../server/lib/reindexing/error.ts | 2 + .../server/lib/reindexing/error_symbols.ts | 1 + .../server/lib/reindexing/op_utils.ts | 3 + .../server/lib/reindexing/reindex_service.ts | 59 +++++++++++++++---- .../server/lib/reindexing/worker.ts | 31 ++++++++-- .../routes/reindex_indices/reindex_handler.ts | 12 +--- .../reindex_indices/reindex_indices.test.ts | 2 +- 8 files changed, 103 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts index a91ae3a9e1af0..41e2314fc2a5d 100644 --- a/x-pack/plugins/upgrade_assistant/common/types.ts +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -30,7 +30,27 @@ export enum ReindexStatus { export const REINDEX_OP_TYPE = 'upgrade-assistant-reindex-operation'; export interface QueueSettings extends SavedObjectAttributes { + /** + * A Unix timestamp of when the reindex operation was enqueued. + * + * @remark + * This is used by the reindexing scheduler to determine execution + * order. + */ queuedAt: number; + + /** + * A Unix timestamp of when the reindex operation was started. + * + * @remark + * Updating this field is useful for _also_ updating the saved object "updated_at" field + * which is used to determine stale or abandoned reindex operations. + * + * For now this is used by the reindex worker scheduler to determine whether we have + * A queue item at the start of the queue. + * + */ + startedAt?: number; } export interface ReindexOptions extends SavedObjectAttributes { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts index f0b3b9146deeb..87b0ddedc69e1 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error.ts @@ -14,6 +14,7 @@ import { MultipleReindexJobsFound, CannotReindexSystemIndexInCurrent, ReindexCannotBeCancelled, + ReindexIsNotInQueue, } from './error_symbols'; export class ReindexError extends Error { @@ -34,6 +35,7 @@ export const error = { reindexTaskCannotBeDeleted: createErrorFactory(ReindexTaskCannotBeDeleted), reindexAlreadyInProgress: createErrorFactory(ReindexAlreadyInProgress), reindexSystemIndex: createErrorFactory(CannotReindexSystemIndexInCurrent), + reindexIsNotInQueue: createErrorFactory(ReindexIsNotInQueue), multipleReindexJobsFound: createErrorFactory(MultipleReindexJobsFound), reindexCannotBeCancelled: createErrorFactory(ReindexCannotBeCancelled), }; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts index 0004ae8520277..f9575eacb6c7f 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/error_symbols.ts @@ -12,6 +12,7 @@ export const ReindexTaskFailed = Symbol('ReindexTaskFailed'); export const ReindexTaskCannotBeDeleted = Symbol('ReindexTaskCannotBeDeleted'); export const ReindexAlreadyInProgress = Symbol('ReindexAlreadyInProgress'); export const CannotReindexSystemIndexInCurrent = Symbol('CannotReindexSystemIndexInCurrent'); +export const ReindexIsNotInQueue = Symbol('ReindexIsNotInQueue'); export const ReindexCannotBeCancelled = Symbol('ReindexCannotBeCancelled'); export const MultipleReindexJobsFound = Symbol('MultipleReindexJobsFound'); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts index dbed7de13f010..ecba02e0d5466 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/op_utils.ts @@ -50,6 +50,9 @@ const orderQueuedReindexOperations = ({ ), }); +export const queuedOpHasStarted = (op: ReindexSavedObject) => + Boolean(op.attributes.reindexOptions?.queueSettings?.startedAt); + export const sortAndOrderReindexOperations = flow( sortReindexOperations, orderQueuedReindexOperations diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index bb14361f220c7..e5d054af7b1b2 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -10,7 +10,6 @@ import { LicensingPluginSetup } from '../../../../licensing/server'; import { IndexGroup, - ReindexOptions, ReindexSavedObject, ReindexStatus, ReindexStep, @@ -62,7 +61,10 @@ export interface ReindexService { * @param indexName * @param opts Additional options when creating a new reindex operation */ - createReindexOperation(indexName: string, opts?: ReindexOptions): Promise; + createReindexOperation( + indexName: string, + opts?: { enqueue?: boolean } + ): Promise; /** * Retrieves all reindex operations that have the given status. @@ -101,7 +103,21 @@ export interface ReindexService { * @param indexName * @param opts As with {@link createReindexOperation} we support this setting. */ - resumeReindexOperation(indexName: string, opts?: ReindexOptions): Promise; + resumeReindexOperation( + indexName: string, + opts?: { enqueue?: boolean } + ): Promise; + + /** + * Update the update_at field on the reindex operation + * + * @remark + * Currently also sets a startedAt field on the SavedObject, not really used + * elsewhere, but is an indication that the object has started being processed. + * + * @param indexName + */ + startQueuedReindexOperation(indexName: string): Promise; /** * Cancel an in-progress reindex operation for a given index. Only allowed when the @@ -570,7 +586,7 @@ export const reindexServiceFactory = ( } }, - async createReindexOperation(indexName: string, opts?: ReindexOptions) { + async createReindexOperation(indexName: string, opts?: { enqueue: boolean }) { if (isSystemIndex(indexName)) { throw error.reindexSystemIndex( `Reindexing system indices are not yet supported within this major version. Upgrade to the latest ${CURRENT_MAJOR_VERSION}.x minor version.` @@ -598,7 +614,10 @@ export const reindexServiceFactory = ( } } - return actions.createReindexOp(indexName, opts); + return actions.createReindexOp( + indexName, + opts?.enqueue ? { queueSettings: { queuedAt: Date.now() } } : undefined + ); }, async findReindexOperation(indexName: string) { @@ -703,7 +722,7 @@ export const reindexServiceFactory = ( }); }, - async resumeReindexOperation(indexName: string, opts?: ReindexOptions) { + async resumeReindexOperation(indexName: string, opts?: { enqueue: boolean }) { const reindexOp = await this.findReindexOperation(indexName); if (!reindexOp) { @@ -717,16 +736,30 @@ export const reindexServiceFactory = ( } else if (op.attributes.status !== ReindexStatus.paused) { throw new Error(`Reindex operation must be paused in order to be resumed.`); } - - const reindexOptions: ReindexOptions | undefined = opts - ? { - ...(op.attributes.reindexOptions ?? {}), - ...opts, - } - : undefined; + const queueSettings = opts?.enqueue ? { queuedAt: Date.now() } : undefined; return actions.updateReindexOp(op, { status: ReindexStatus.inProgress, + reindexOptions: queueSettings ? { queueSettings } : undefined, + }); + }); + }, + + async startQueuedReindexOperation(indexName: string) { + const reindexOp = await this.findReindexOperation(indexName); + + if (!reindexOp) { + throw error.indexNotFound(`No reindex operation found for index ${indexName}`); + } + + if (!reindexOp.attributes.reindexOptions?.queueSettings) { + throw error.reindexIsNotInQueue(`Reindex operation ${indexName} is not in the queue.`); + } + + return actions.runWhileLocked(reindexOp, async lockedReindexOp => { + const { reindexOptions } = lockedReindexOp.attributes; + reindexOptions!.queueSettings!.startedAt = Date.now(); + return actions.updateReindexOp(lockedReindexOp, { reindexOptions, }); }); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts index b0311358e8f3f..06349d069a813 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/worker.ts @@ -6,11 +6,11 @@ import { IClusterClient, Logger, SavedObjectsClientContract, FakeRequest } from 'src/core/server'; import moment from 'moment'; import { ReindexSavedObject, ReindexStatus } from '../../../common/types'; -import { CredentialStore } from './credential_store'; +import { Credential, CredentialStore } from './credential_store'; import { reindexActionsFactory } from './reindex_actions'; import { ReindexService, reindexServiceFactory } from './reindex_service'; import { LicensingPluginSetup } from '../../../../licensing/server'; -import { sortAndOrderReindexOperations } from './op_utils'; +import { sortAndOrderReindexOperations, queuedOpHasStarted } from './op_utils'; const POLL_INTERVAL = 30000; // If no nodes have been able to update this index in 2 minutes (due to missing credentials), set to paused. @@ -130,17 +130,40 @@ export class ReindexWorker { } }; + private getCredentialScopedReindexService = (credential: Credential) => { + const fakeRequest: FakeRequest = { headers: credential }; + const scopedClusterClient = this.clusterClient.asScoped(fakeRequest); + const callAsCurrentUser = scopedClusterClient.callAsCurrentUser.bind(scopedClusterClient); + const actions = reindexActionsFactory(this.client, callAsCurrentUser); + return reindexServiceFactory( + callAsCurrentUser, + actions, + this.log, + this.licensing, + this.apmIndexPatterns + ); + }; + private updateInProgressOps = async () => { try { const inProgressOps = await this.reindexService.findAllByStatus(ReindexStatus.inProgress); const { parallel, queue } = sortAndOrderReindexOperations(inProgressOps); - const [firstOpInQueue] = queue; + let [firstOpInQueue] = queue; - if (firstOpInQueue) { + if (firstOpInQueue && !queuedOpHasStarted(firstOpInQueue)) { this.log.debug( `Queue detected; current length ${queue.length}, current item ReindexOperation(id: ${firstOpInQueue.id}, indexName: ${firstOpInQueue.attributes.indexName})` ); + const credential = this.credentialStore.get(firstOpInQueue); + if (credential) { + const service = this.getCredentialScopedReindexService(credential); + firstOpInQueue = await service.startQueuedReindexOperation( + firstOpInQueue.attributes.indexName + ); + // Re-associate the credentials + this.credentialStore.set(firstOpInQueue, credential); + } } this.inProgressOps = parallel.concat(firstOpInQueue ? [firstOpInQueue] : []); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts index e640d03791cce..74c349d894839 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_handler.ts @@ -8,7 +8,7 @@ import { IScopedClusterClient, Logger, SavedObjectsClientContract } from 'kibana import { LicensingPluginSetup } from '../../../../licensing/server'; -import { ReindexOperation, ReindexOptions, ReindexStatus } from '../../../common/types'; +import { ReindexOperation, ReindexStatus } from '../../../common/types'; import { reindexActionsFactory } from '../../lib/reindexing/reindex_actions'; import { reindexServiceFactory } from '../../lib/reindexing'; @@ -53,17 +53,11 @@ export const reindexHandler = async ({ const existingOp = await reindexService.findReindexOperation(indexName); - const opts: ReindexOptions | undefined = reindexOptions - ? { - queueSettings: reindexOptions.enqueue ? { queuedAt: Date.now() } : undefined, - } - : undefined; - // If the reindexOp already exists and it's paused, resume it. Otherwise create a new one. const reindexOp = existingOp && existingOp.attributes.status === ReindexStatus.paused - ? await reindexService.resumeReindexOperation(indexName, opts) - : await reindexService.createReindexOperation(indexName, opts); + ? await reindexService.resumeReindexOperation(indexName, reindexOptions) + : await reindexService.createReindexOperation(indexName, reindexOptions); // Add users credentials for the worker to use credentialStore.set(reindexOp, headers); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts index 30beecd8a5de1..860a6a07f6f2e 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/reindex_indices/reindex_indices.test.ts @@ -263,7 +263,7 @@ describe('reindex API', () => { describe('POST /api/upgrade_assistant/reindex/batch', () => { const queueSettingsArg = { - queueSettings: { queuedAt: expect.any(Number) }, + enqueue: true, }; it('creates a collection of index operations', async () => { mockReindexService.createReindexOperation