diff --git a/package.json b/package.json index c0d3c2209acae..cf9158c3a59b8 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "dependencies": { "@babel/core": "^7.5.5", "@babel/register": "^7.5.5", - "@elastic/charts": "^13.5.12", + "@elastic/charts": "^14.0.0", "@elastic/datemath": "5.0.2", "@elastic/ems-client": "1.0.5", "@elastic/eui": "14.8.0", diff --git a/packages/eslint-config-kibana/.eslintrc.js b/packages/eslint-config-kibana/.eslintrc.js index 36f0b95c8e69b..98fa62021b5bb 100644 --- a/packages/eslint-config-kibana/.eslintrc.js +++ b/packages/eslint-config-kibana/.eslintrc.js @@ -32,6 +32,10 @@ module.exports = { from: 'x-pack', toRelative: 'x-pack', }, + { + from: 'react-router', + to: 'react-router-dom', + }, ], ], } diff --git a/packages/kbn-ui-framework/doc_site/src/components/guide_nav/guide_nav.js b/packages/kbn-ui-framework/doc_site/src/components/guide_nav/guide_nav.js index cee256da79513..53bc42ce33276 100644 --- a/packages/kbn-ui-framework/doc_site/src/components/guide_nav/guide_nav.js +++ b/packages/kbn-ui-framework/doc_site/src/components/guide_nav/guide_nav.js @@ -20,9 +20,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { - Link, -} from 'react-router'; +import { Link } from 'react-router'; // eslint-disable-line import classNames from 'classnames'; diff --git a/packages/kbn-ui-framework/doc_site/src/index.js b/packages/kbn-ui-framework/doc_site/src/index.js index d82df375c59f4..33aa3f582d9ba 100644 --- a/packages/kbn-ui-framework/doc_site/src/index.js +++ b/packages/kbn-ui-framework/doc_site/src/index.js @@ -24,10 +24,7 @@ import 'regenerator-runtime/runtime'; import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; -import { - Router, - hashHistory, -} from 'react-router'; +import { Router, hashHistory } from 'react-router'; // eslint-disable-line // Store. import configureStore from './store/configure_store'; diff --git a/packages/kbn-ui-framework/doc_site/src/store/configure_store.js b/packages/kbn-ui-framework/doc_site/src/store/configure_store.js index 2d6a1b31b0d19..cb14900f88e89 100644 --- a/packages/kbn-ui-framework/doc_site/src/store/configure_store.js +++ b/packages/kbn-ui-framework/doc_site/src/store/configure_store.js @@ -23,11 +23,8 @@ import { compose, } from 'redux'; import thunk from 'redux-thunk'; -import { browserHistory } from 'react-router'; -import { - routerMiddleware, - routerReducer, -} from 'react-router-redux'; +import { browserHistory } from 'react-router'; // eslint-disable-line +import { routerMiddleware, routerReducer } from 'react-router-redux'; import codeViewerReducer from './reducers/code_viewer_reducer'; import sandboxReducer from './reducers/sandbox_reducer'; diff --git a/packages/kbn-ui-framework/doc_site/src/views/not_found/not_found_view.js b/packages/kbn-ui-framework/doc_site/src/views/not_found/not_found_view.js index 34c3a3f6bbe2e..f42c5b1d0c63a 100644 --- a/packages/kbn-ui-framework/doc_site/src/views/not_found/not_found_view.js +++ b/packages/kbn-ui-framework/doc_site/src/views/not_found/not_found_view.js @@ -19,9 +19,7 @@ import React from 'react'; -import { - Link, -} from 'react-router'; +import { Link } from 'react-router'; // eslint-disable-line export const NotFoundView = () => (
diff --git a/packages/kbn-ui-framework/package.json b/packages/kbn-ui-framework/package.json index d034e4393f58f..ca594fe44b6c7 100644 --- a/packages/kbn-ui-framework/package.json +++ b/packages/kbn-ui-framework/package.json @@ -60,7 +60,6 @@ "react-dom": "^16.2.0", "react-redux": "^5.0.6", "react-router": "^3.2.0", - "react-router-dom": "4.2.2", "react-router-redux": "^4.0.8", "redux": "3.7.2", "redux-thunk": "2.2.0", diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index fcc232345a802..0ac2f59525c32 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -39,6 +39,7 @@ interface RequestFixtureOptions { path?: string; method?: RouteMethod; socket?: Socket; + routeTags?: string[]; } function createKibanaRequestMock({ @@ -49,6 +50,7 @@ function createKibanaRequestMock({ query = {}, method = 'get', socket = new Socket(), + routeTags, }: RequestFixtureOptions = {}) { const queryString = querystring.stringify(query); return KibanaRequest.from( @@ -61,10 +63,11 @@ function createKibanaRequestMock({ method, url: { path, + pathname: path, query: queryString, search: queryString ? `?${queryString}` : queryString, }, - route: { settings: {} }, + route: { settings: { tags: routeTags } }, raw: { req: { socket }, }, diff --git a/src/core/server/saved_objects/service/index.ts b/src/core/server/saved_objects/service/index.ts index 15f46711fc94b..cf0769fced460 100644 --- a/src/core/server/saved_objects/service/index.ts +++ b/src/core/server/saved_objects/service/index.ts @@ -34,6 +34,7 @@ export interface SavedObjectsLegacyService { addScopedSavedObjectsClientWrapperFactory: SavedObjectsClientProvider< Request >['addClientWrapperFactory']; + setScopedSavedObjectsClientFactory: SavedObjectsClientProvider['setClientFactory']; getScopedSavedObjectsClient: SavedObjectsClientProvider['getClient']; SavedObjectsClient: typeof SavedObjectsClient; types: string[]; diff --git a/src/core/server/saved_objects/service/lib/scoped_client_provider.ts b/src/core/server/saved_objects/service/lib/scoped_client_provider.ts index ad1ceb60cdb86..87607acd94fc4 100644 --- a/src/core/server/saved_objects/service/lib/scoped_client_provider.ts +++ b/src/core/server/saved_objects/service/lib/scoped_client_provider.ts @@ -100,7 +100,7 @@ export class SavedObjectsClientProvider { this._wrapperFactories.add(priority, { id, factory }); } - setClientFactory(customClientFactory: SavedObjectsClientFactory) { + setClientFactory(customClientFactory: SavedObjectsClientFactory) { if (this._clientFactory !== this._originalClientFactory) { throw new Error(`custom client factory is already set, unable to replace the current one`); } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 14943fc96f268..73626775381d7 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1492,6 +1492,8 @@ export interface SavedObjectsLegacyService { // (undocumented) schema: SavedObjectsSchema; // (undocumented) + setScopedSavedObjectsClientFactory: SavedObjectsClientProvider['setClientFactory']; + // (undocumented) types: string[]; } diff --git a/src/legacy/core_plugins/data/public/shim/legacy_module.ts b/src/legacy/core_plugins/data/public/shim/legacy_module.ts index b0ed3d43a4c8c..edc389b411971 100644 --- a/src/legacy/core_plugins/data/public/shim/legacy_module.ts +++ b/src/legacy/core_plugins/data/public/shim/legacy_module.ts @@ -24,7 +24,7 @@ import { wrapInI18nContext } from 'ui/i18n'; // @ts-ignore import { uiModules } from 'ui/modules'; import { npStart } from 'ui/new_platform'; -import { FilterBar, ApplyFiltersPopover } from '../filter'; +import { FilterBar } from '../filter'; import { IndexPatterns } from '../index_patterns/index_patterns'; /** @internal */ @@ -70,54 +70,7 @@ export const initLegacyModule = once((indexPatterns: IndexPatterns): void => { ['className', { watchDepth: 'reference' }], ['pluginDataStart', { watchDepth: 'reference' }], ]); - }) - .directive('applyFiltersPopover', () => { - return { - restrict: 'E', - template: '', - compile: (elem: any) => { - const child = document.createElement('apply-filters-popover-helper'); - - // Copy attributes to the child directive - for (const attr of elem[0].attributes) { - child.setAttribute(attr.name, attr.value); - } - - // Add a key attribute that will force a full rerender every time that - // a filter changes. - child.setAttribute('key', 'key'); - - // Append helper directive - elem.append(child); - - const linkFn = ($scope: any, _: any, $attr: any) => { - // Watch only for filter changes to update key. - $scope.$watch( - () => { - return $scope.$eval($attr.filters) || []; - }, - (newVal: any) => { - $scope.key = Date.now(); - }, - true - ); - }; - - return linkFn; - }, - }; - }) - .directive('applyFiltersPopoverHelper', (reactDirective: any) => - reactDirective(wrapInI18nContext(ApplyFiltersPopover), [ - ['filters', { watchDepth: 'collection' }], - ['onCancel', { watchDepth: 'reference' }], - ['onSubmit', { watchDepth: 'reference' }], - ['indexPatterns', { watchDepth: 'collection' }], - - // Key is needed to trigger a full rerender of the component - 'key', - ]) - ); + }); uiModules.get('kibana/index_patterns').value('indexPatterns', indexPatterns); }); diff --git a/src/legacy/core_plugins/expressions/public/np_ready/public/execute.ts b/src/legacy/core_plugins/expressions/public/np_ready/public/execute.ts index 8043e0fb6e3f9..45d5c07cd1b26 100644 --- a/src/legacy/core_plugins/expressions/public/np_ready/public/execute.ts +++ b/src/legacy/core_plugins/expressions/public/np_ready/public/execute.ts @@ -17,7 +17,7 @@ * under the License. */ -import { fromExpression } from '@kbn/interpreter/target/common'; +import { fromExpression, toExpression } from '@kbn/interpreter/target/common'; import { DataAdapter, RequestAdapter, Adapters } from '../../../../../../plugins/inspector/public'; import { getInterpreter } from './services'; import { ExpressionAST, IExpressionLoaderParams, IInterpreterResult } from './types'; @@ -38,17 +38,18 @@ export class ExpressionDataHandler { private inspectorAdapters: Adapters; private promise: Promise; + public isPending: boolean = true; constructor(expression: string | ExpressionAST, params: IExpressionLoaderParams) { if (typeof expression === 'string') { this.expression = expression; this.ast = fromExpression(expression) as ExpressionAST; } else { this.ast = expression; - this.expression = ''; + this.expression = toExpression(this.ast); } this.abortController = new AbortController(); - this.inspectorAdapters = this.getActiveInspectorAdapters(); + this.inspectorAdapters = params.inspectorAdapters || this.getActiveInspectorAdapters(); const getInitialContext = () => ({ type: 'kibana_context', @@ -58,11 +59,21 @@ export class ExpressionDataHandler { const defaultContext = { type: 'null' }; const interpreter = getInterpreter(); - this.promise = interpreter.interpretAst(this.ast, params.context || defaultContext, { - getInitialContext, - inspectorAdapters: this.inspectorAdapters, - abortSignal: this.abortController.signal, - }); + this.promise = interpreter + .interpretAst(this.ast, params.context || defaultContext, { + getInitialContext, + inspectorAdapters: this.inspectorAdapters, + abortSignal: this.abortController.signal, + }) + .then( + (v: IInterpreterResult) => { + this.isPending = false; + return v; + }, + () => { + this.isPending = false; + } + ); } cancel = () => { diff --git a/src/legacy/core_plugins/expressions/public/np_ready/public/loader.test.ts b/src/legacy/core_plugins/expressions/public/np_ready/public/loader.test.ts index a3caa1c47b150..4c3bc76af351d 100644 --- a/src/legacy/core_plugins/expressions/public/np_ready/public/loader.test.ts +++ b/src/legacy/core_plugins/expressions/public/np_ready/public/loader.test.ts @@ -67,7 +67,7 @@ describe('execute helper function', () => { }); describe('ExpressionLoader', () => { - const expressionString = ''; + const expressionString = 'demodata'; describe('constructor', () => { it('accepts expression string', () => { @@ -134,6 +134,8 @@ describe('ExpressionLoader', () => { (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ getData: () => true, cancel: cancelMock, + isPending: () => true, + inspect: () => {}, })); const expressionLoader = new ExpressionLoader(element, expressionString, {}); @@ -160,10 +162,15 @@ describe('ExpressionLoader', () => { (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ getData, cancel: cancelMock, + isPending: () => true, + inspect: () => {}, })); + (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ getData, cancel: cancelMock, + isPending: () => true, + inspect: () => {}, })); const expressionLoader = new ExpressionLoader(element, expressionString, {}); @@ -193,6 +200,8 @@ describe('ExpressionLoader', () => { (ExpressionDataHandler as jest.Mock).mockImplementationOnce(() => ({ getData, cancel: cancelMock, + isPending: () => true, + inspect: () => {}, })); const expressionLoader = new ExpressionLoader(element, expressionString, {}); diff --git a/src/legacy/core_plugins/expressions/public/np_ready/public/loader.ts b/src/legacy/core_plugins/expressions/public/np_ready/public/loader.ts index 709fbc78a9b52..2213cd30010b2 100644 --- a/src/legacy/core_plugins/expressions/public/np_ready/public/loader.ts +++ b/src/legacy/core_plugins/expressions/public/np_ready/public/loader.ts @@ -38,11 +38,12 @@ export class ExpressionLoader { private loadingSubject: Subject; private data: Data; private params: IExpressionLoaderParams = {}; + private ignoreNextResponse = false; constructor( element: HTMLElement, - expression: string | ExpressionAST, - params: IExpressionLoaderParams + expression?: string | ExpressionAST, + params?: IExpressionLoaderParams ) { this.dataSubject = new Subject(); this.data$ = this.dataSubject.asObservable().pipe(share()); @@ -65,7 +66,9 @@ export class ExpressionLoader { this.setParams(params); - this.loadData(expression, this.params); + if (expression) { + this.loadData(expression, this.params); + } } destroy() { @@ -117,9 +120,10 @@ export class ExpressionLoader { update(expression?: string | ExpressionAST, params?: IExpressionLoaderParams): void { this.setParams(params); + this.loadingSubject.next(); if (expression) { this.loadData(expression, this.params); - } else { + } else if (this.data) { this.render(this.data); } } @@ -128,18 +132,22 @@ export class ExpressionLoader { expression: string | ExpressionAST, params: IExpressionLoaderParams ): Promise => { - this.loadingSubject.next(); - if (this.dataHandler) { + if (this.dataHandler && this.dataHandler.isPending) { + this.ignoreNextResponse = true; this.dataHandler.cancel(); } this.setParams(params); this.dataHandler = new ExpressionDataHandler(expression, params); + if (!params.inspectorAdapters) params.inspectorAdapters = this.dataHandler.inspect(); const data = await this.dataHandler.getData(); + if (this.ignoreNextResponse) { + this.ignoreNextResponse = false; + return; + } this.dataSubject.next(data); }; private render(data: Data): void { - this.loadingSubject.next(); this.renderHandler.render(data, this.params.extraHandlers); } @@ -148,23 +156,16 @@ export class ExpressionLoader { return; } - if (params.searchContext && this.params.searchContext) { + if (params.searchContext) { this.params.searchContext = _.defaults( {}, params.searchContext, - this.params.searchContext + this.params.searchContext || {} ) as any; } if (params.extraHandlers && this.params) { this.params.extraHandlers = params.extraHandlers; } - - if (!Object.keys(this.params).length) { - this.params = { - ...params, - searchContext: { type: 'kibana_context', ...(params.searchContext || {}) }, - }; - } } } diff --git a/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts b/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts index d232a97c3c34c..bcb8d00663e01 100644 --- a/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts +++ b/src/legacy/core_plugins/interpreter/public/functions/esaggs.ts @@ -22,9 +22,15 @@ import { i18n } from '@kbn/i18n'; import { AggConfigs } from 'ui/agg_types/agg_configs'; import { createFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; import chrome from 'ui/chrome'; -import { TimeRange } from 'src/plugins/data/public'; + +import { Query, TimeRange, esFilters } from 'src/plugins/data/public'; import { SearchSource } from '../../../../ui/public/courier/search_source'; -import { FilterBarQueryFilterProvider } from '../../../../ui/public/filter_manager/query_filter'; +// @ts-ignore +import { + FilterBarQueryFilterProvider, + QueryFilter, +} from '../../../../ui/public/filter_manager/query_filter'; + import { buildTabularInspectorData } from '../../../../ui/public/inspector/build_tabular_inspector_data'; import { getRequestInspectorStats, @@ -32,15 +38,30 @@ import { } from '../../../../ui/public/courier/utils/courier_inspector_utils'; import { calculateObjectHash } from '../../../../ui/public/vis/lib/calculate_object_hash'; import { getTime } from '../../../../ui/public/timefilter'; -import { RequestHandlerParams } from '../../../../ui/public/visualize/loader/embedded_visualize_handler'; -import { KibanaContext, KibanaDatatable } from '../../common'; -import { ExpressionFunction, KibanaDatatableColumn } from '../../types'; -import { start as data } from '../../../data/public/legacy'; + +export interface RequestHandlerParams { + searchSource: SearchSource; + aggs: AggConfigs; + timeRange?: TimeRange; + query?: Query; + filters?: esFilters.Filter[]; + forceFetch: boolean; + queryFilter: QueryFilter; + uiState?: PersistedState; + partialRows?: boolean; + inspectorAdapters: Adapters; + metricsAtAllLevels?: boolean; + visParams?: any; + abortSignal?: AbortSignal; +} // @ts-ignore import { tabifyAggResponse } from '../../../../ui/public/agg_response/tabify/tabify'; -// @ts-ignore -import { SearchSourceProvider } from '../../../../ui/public/courier/search_source'; +import { KibanaContext, KibanaDatatable } from '../../common'; +import { ExpressionFunction, KibanaDatatableColumn } from '../../types'; +import { start as data } from '../../../data/public/legacy'; +import { PersistedState } from '../../../../ui/public/persisted_state'; +import { Adapters } from '../../../../../plugins/inspector/public'; const name = 'esaggs'; diff --git a/src/legacy/core_plugins/interpreter/public/renderers/visualization.ts b/src/legacy/core_plugins/interpreter/public/renderers/visualization.tsx similarity index 73% rename from src/legacy/core_plugins/interpreter/public/renderers/visualization.ts rename to src/legacy/core_plugins/interpreter/public/renderers/visualization.tsx index bedba6bfacede..9de6cdeaf5ec3 100644 --- a/src/legacy/core_plugins/interpreter/public/renderers/visualization.ts +++ b/src/legacy/core_plugins/interpreter/public/renderers/visualization.tsx @@ -18,9 +18,11 @@ */ import chrome from 'ui/chrome'; -import { visualizationLoader } from 'ui/visualize/loader/visualization_loader'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; // @ts-ignore -import { VisProvider } from 'ui/visualize/loader/vis'; +import { VisProvider } from '../../../../ui/public/visualize/loader/vis'; +import { Visualization } from '../../../../ui/public/visualize/components'; export const visualization = () => ({ name: 'visualization', @@ -50,17 +52,27 @@ export const visualization = () => ({ type: visType, params: visConfig, }); - handlers.vis.eventsSubject = handlers.eventsSubject; } + handlers.vis.eventsSubject = { next: handlers.event }; + const uiState = handlers.uiState || handlers.vis.getUiState(); - handlers.onDestroy(() => visualizationLoader.destroy()); + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); - await visualizationLoader - .render(domNode, handlers.vis, visData, handlers.vis.params, uiState, params) - .then(() => { - if (handlers.done) handlers.done(); - }); + const listenOnChange = params ? params.listenOnChange : false; + render( + , + domNode + ); }, }); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html index 68c8131fa1a7b..f644f3811e3e0 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app.html @@ -42,13 +42,6 @@ index-patterns="indexPatterns" > - -
diff --git a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx index adf0e1e084a64..548a66297a3f9 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/dashboard_app_controller.tsx @@ -56,9 +56,7 @@ import { capabilities } from 'ui/capabilities'; import { Subscription } from 'rxjs'; import { npStart } from 'ui/new_platform'; import { SavedObjectFinder } from 'ui/saved_objects/components/saved_object_finder'; -import { extractTimeFilter, changeTimeFilter } from '../../../../../plugins/data/public'; import { start as data } from '../../../data/public/legacy'; -import { esFilters } from '../../../../../plugins/data/public'; import { DashboardContainer, @@ -417,31 +415,6 @@ export class DashboardAppController { queryFilter.setFilters(filters); }; - $scope.onCancelApplyFilters = () => { - $scope.appState.$newFilters = []; - }; - - $scope.onApplyFilters = filters => { - if (filters.length) { - // All filters originated from one visualization. - const indexPatternId = filters[0].meta.index; - const indexPattern = _.find( - $scope.indexPatterns, - (p: IndexPattern) => p.id === indexPatternId - ); - if (indexPattern && indexPattern.timeFieldName) { - const { timeRangeFilter, restOfFilters } = extractTimeFilter( - indexPattern.timeFieldName, - filters - ); - queryFilter.addFilters(restOfFilters); - if (timeRangeFilter) changeTimeFilter(timefilter, timeRangeFilter); - } - } - - $scope.appState.$newFilters = []; - }; - $scope.onQuerySaved = savedQuery => { $scope.savedQuery = savedQuery; }; @@ -514,12 +487,6 @@ export class DashboardAppController { } ); - $scope.$watch('appState.$newFilters', (filters: esFilters.Filter[] = []) => { - if (filters.length === 1) { - $scope.onApplyFilters(filters); - } - }); - $scope.indexPatterns = []; $scope.$watch('model.query', (newQuery: Query) => { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap index e23102a0785fc..1ed05035f5f4c 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/listing/__snapshots__/dashboard_listing.test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`after fetch hideWriteControls 1`] = ` - `; exports[`after fetch initialFilter 1`] = ` - `; exports[`after fetch renders call to action when no dashboards exist 1`] = ` - `; exports[`after fetch renders table rows 1`] = ` - `; exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` - `; exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` - `; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.js b/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.js index d8216361562e2..c222fcd3c928c 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.js @@ -23,8 +23,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui'; +import { npStart } from 'ui/new_platform'; -import { TableListView } from './../../table_list_view'; +import { TableListView } from '../../../../../../../src/plugins/kibana_react/public'; export const EMPTY_FILTER = ''; @@ -58,6 +59,8 @@ export class DashboardListing extends React.Component { tableListTitle={i18n.translate('kbn.dashboard.listing.dashboardsTitle', { defaultMessage: 'Dashboards', })} + toastNotifications={npStart.core.notifications.toasts} + uiSettings={npStart.core.uiSettings} /> ); } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.test.js b/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.test.js index 57de395525e1b..be542c60bfe7a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.test.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/listing/dashboard_listing.test.js @@ -42,6 +42,17 @@ jest.mock( { virtual: true } ); +jest.mock('ui/new_platform', () => { + return { + npStart: { + core: { + notifications: { toasts: { } }, + uiSettings: { get: jest.fn(() => 10) }, + }, + }, + }; +}); + import React from 'react'; import { shallow } from 'enzyme'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js index f501161136801..58a0075e94b99 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/editor/editor.js @@ -31,7 +31,7 @@ import editorTemplate from './editor.html'; import { DashboardConstants } from '../../dashboard/dashboard_constants'; import { VisualizeConstants } from '../visualize_constants'; import { getEditBreadcrumbs, getCreateBreadcrumbs } from '../breadcrumbs'; -import { extractTimeFilter, changeTimeFilter } from '../../../../../../plugins/data/public'; + import { addHelpMenuToAppChrome } from '../help_menu/help_menu_util'; import { @@ -342,23 +342,6 @@ function VisEditor( queryFilter.setFilters(filters); }; - $scope.onCancelApplyFilters = () => { - $scope.state.$newFilters = []; - }; - - $scope.onApplyFilters = filters => { - const { timeRangeFilter, restOfFilters } = extractTimeFilter($scope.indexPattern.timeFieldName, filters); - queryFilter.addFilters(restOfFilters); - if (timeRangeFilter) changeTimeFilter(timefilter, timeRangeFilter); - $scope.state.$newFilters = []; - }; - - $scope.$watch('state.$newFilters', (filters = []) => { - if (filters.length === 1) { - $scope.onApplyFilters(filters); - } - }); - $scope.showSaveQuery = capabilities.visualize.saveQuery; $scope.$watch(() => capabilities.visualize.saveQuery, (newCapability) => { @@ -457,6 +440,12 @@ function VisEditor( next: $scope.fetch })); + subscriptions.add(subscribeWithScope($scope, timefilter.getAutoRefreshFetch$(), { + next: () => { + $scope.vis.forceReload(); + } + })); + $scope.$on('$destroy', function () { if ($scope._handler) { $scope._handler.destroy(); diff --git a/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.ts index 318686b26f6f2..60cf7c7ec1928 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable.ts @@ -17,39 +17,53 @@ * under the License. */ -import _ from 'lodash'; -import { EmbeddedVisualizeHandler } from 'ui/visualize/loader/embedded_visualize_handler'; +import _, { forEach } from 'lodash'; +import { StaticIndexPattern } from 'ui/index_patterns'; +import { PersistedState } from 'ui/persisted_state'; import { Subscription } from 'rxjs'; import * as Rx from 'rxjs'; +import { buildPipeline } from 'ui/visualize/loader/pipeline_helpers'; +import { SavedObject } from 'ui/saved_objects/saved_object'; +import { Vis } from 'ui/vis'; +import { SearchSource } from 'ui/courier'; +import { queryGeohashBounds } from 'ui/visualize/loader/utils'; +import { getTableAggs } from 'ui/visualize/loader/pipeline_helpers/utilities'; +import { AppState } from 'ui/state_management/app_state'; +import { npStart } from 'ui/new_platform'; +import { IExpressionLoaderParams } from '../../../../expressions/public/np_ready/public/types'; +import { start as expressions } from '../../../../expressions/public/legacy'; +import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; +import { Query } from '../../../../data/public'; import { TimeRange, onlyDisabledFiltersChanged, esFilters, } from '../../../../../../plugins/data/public'; -import { Query } from '../../../../data/public'; -import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; - import { - AppState, - Container, - Embeddable, EmbeddableInput, EmbeddableOutput, - PersistedState, - StaticIndexPattern, - VisSavedObject, - VisualizeLoader, - VisualizeLoaderParams, - VisualizeUpdateParams, -} from '../kibana_services'; + Embeddable, + Container, + APPLY_FILTER_TRIGGER, +} from '../../../../../../plugins/embeddable/public'; +import { dispatchRenderComplete } from '../../../../../../plugins/kibana_utils/public'; +import { mapAndFlattenFilters } from '../../../../../../plugins/data/public'; const getKeys = (o: T): Array => Object.keys(o) as Array; +export interface VisSavedObject extends SavedObject { + vis: Vis; + description?: string; + searchSource: SearchSource; + title: string; + uiStateJSON?: string; + destroy: () => void; +} + export interface VisualizeEmbeddableConfiguration { savedVisualization: VisSavedObject; indexPatterns?: StaticIndexPattern[]; editUrl: string; - loader: VisualizeLoader; editable: boolean; appState?: AppState; uiState?: PersistedState; @@ -73,24 +87,28 @@ export interface VisualizeOutput extends EmbeddableOutput { visTypeName: string; } +type ExpressionLoader = InstanceType; + export class VisualizeEmbeddable extends Embeddable { + private handler?: ExpressionLoader; private savedVisualization: VisSavedObject; - private loader: VisualizeLoader; private appState: AppState | undefined; private uiState: PersistedState; - private handler?: EmbeddedVisualizeHandler; private timeRange?: TimeRange; private query?: Query; private title?: string; private filters?: esFilters.Filter[]; private visCustomizations: VisualizeInput['vis']; - private subscription: Subscription; + private subscriptions: Subscription[] = []; + private expression: string = ''; + private actions: any = {}; + private vis: Vis; + private domNode: any; public readonly type = VISUALIZE_EMBEDDABLE_TYPE; constructor( { savedVisualization, - loader, editUrl, indexPatterns, editable, @@ -112,8 +130,12 @@ export class VisualizeEmbeddable extends Embeddable { - this.handleChanges(); - }); + this.subscriptions.push( + Rx.merge(this.getOutput$(), this.getInput$()).subscribe(() => { + this.handleChanges(); + }) + ); } public getVisualizationDescription() { return this.savedVisualization.description; } - public getInspectorAdapters() { + public getInspectorAdapters = () => { if (!this.handler) { return undefined; } - return this.handler.inspectorAdapters; - } + return this.handler.inspect(); + }; + + public openInspector = () => { + if (this.handler) { + return this.handler.openInspector(this.getTitle() || ''); + } + }; /** * Transfers all changes in the containerState.customization into @@ -170,87 +202,148 @@ export class VisualizeEmbeddable extends Embeddable { + if (event.disabled || !eventName) { + return; + } else { + this.actions[eventName] = event.defaultAction; + } + }); + + // This is a hack to give maps visualizations access to data in the + // globalState, since they can no longer access it via searchSource. + // TODO: Remove this as a part of elastic/kibana#30593 + this.vis.API.getGeohashBounds = () => { + return queryGeohashBounds(this.savedVisualization.vis, { + filters: this.filters, + query: this.query, + searchSource: this.savedVisualization.searchSource, + }); + }; + + // this is a hack to make editor still work, will be removed once we clean up editor + this.vis.hasInspector = () => { + const visTypesWithoutInspector = ['markdown', 'input_control_vis', 'metrics', 'vega']; + if (visTypesWithoutInspector.includes(this.vis.type.name)) { + return false; + } + return this.getInspectorAdapters(); }; + + this.vis.openInspector = this.openInspector; + + const div = document.createElement('div'); + div.className = `visualize panel-content panel-content--fullWidth`; + domNode.appendChild(div); + this.domNode = div; + + this.handler = new expressions.ExpressionLoader(this.domNode); + + this.subscriptions.push( + this.handler.events$.subscribe(async event => { + if (this.actions[event.name]) { + event.data.aggConfigs = getTableAggs(this.vis); + const filters: esFilters.Filter[] = this.actions[event.name](event.data) || []; + const mappedFilters = mapAndFlattenFilters(filters); + const timeFieldName = this.vis.indexPattern.timeFieldName; + + npStart.plugins.uiActions.executeTriggerActions(APPLY_FILTER_TRIGGER, { + embeddable: this, + filters: mappedFilters, + timeFieldName, + }); + } + }) + ); + + div.setAttribute('data-title', this.output.title || ''); + if (this.savedVisualization.description) { - dataAttrs.description = this.savedVisualization.description; + div.setAttribute('data-description', this.savedVisualization.description); } - const handlerParams: VisualizeLoaderParams = { - appState: this.appState, - uiState: this.uiState, - // Append visualization to container instead of replacing its content - append: true, - timeRange: _.cloneDeep(this.input.timeRange), - query: this.query, - filters: this.filters, - cssClass: `panel-content panel-content--fullWidth`, - dataAttrs, - }; + div.setAttribute('data-test-subj', 'visualizationLoader'); + div.setAttribute('data-shared-item', ''); + div.setAttribute('data-rendering-count', '0'); + div.setAttribute('data-render-complete', 'false'); + + this.subscriptions.push( + this.handler.loading$.subscribe(() => { + div.setAttribute('data-render-complete', 'false'); + div.setAttribute('data-loading', ''); + }) + ); - this.handler = this.loader.embedVisualizationWithSavedObject( - domNode, - this.savedVisualization, - handlerParams + this.subscriptions.push( + this.handler.render$.subscribe(count => { + div.removeAttribute('data-loading'); + div.setAttribute('data-render-complete', 'true'); + div.setAttribute('data-rendering-count', count.toString()); + dispatchRenderComplete(div); + }) ); + + this.updateHandler(); } public destroy() { super.destroy(); - if (this.subscription) { - this.subscription.unsubscribe(); - } + this.subscriptions.forEach(s => s.unsubscribe()); this.uiState.off('change', this.uiStateChangeHandler); + this.savedVisualization.vis.removeListener('reload', this.reload); + this.savedVisualization.vis.removeListener('update', this.handleVisUpdate); this.savedVisualization.destroy(); if (this.handler) { this.handler.destroy(); @@ -258,12 +351,44 @@ export class VisualizeEmbeddable extends Embeddable { + this.handleVisUpdate(); + }; + + private async updateHandler() { + const expressionParams: IExpressionLoaderParams = { + searchContext: { + type: 'kibana_context', + timeRange: this.timeRange, + query: this.input.query, + filters: this.input.filters, + }, + extraHandlers: { + vis: this.vis, + uiState: this.uiState, + }, + }; + this.expression = await buildPipeline(this.vis, { + searchSource: this.savedVisualization.searchSource, + timeRange: this.timeRange, + }); + + this.vis.filters = { timeRange: this.timeRange }; + if (this.handler) { - this.handler.reload(); + this.handler.update(this.expression, expressionParams); } } + private handleVisUpdate = async () => { + if (this.appState) { + this.appState.vis = this.savedVisualization.vis.getState(); + this.appState.save(); + } + + this.updateHandler(); + }; + private uiStateChangeHandler = () => { this.updateInput({ ...this.uiState.toJSON(), diff --git a/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory.tsx b/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory.tsx index c1ce4f67cfdb3..15ad9a33232ef 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/embeddable/visualize_embeddable_factory.tsx @@ -36,7 +36,6 @@ import { EmbeddableFactory, EmbeddableOutput, ErrorEmbeddable, - getVisualizeLoader, VisSavedObject, } from '../kibana_services'; @@ -131,7 +130,6 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< const visId = savedObject.id as string; const editUrl = visId ? addBasePath(`/app/kibana${savedVisualizations.urlFor(visId)}`) : ''; - const loader = await getVisualizeLoader(); const isLabsEnabled = config.get('visualize:enableLabs'); if (!isLabsEnabled && savedObject.vis.type.stage === 'experimental') { @@ -143,7 +141,6 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< return new VisualizeEmbeddable( { savedVisualization: savedObject, - loader, indexPatterns, editUrl, editable: this.isEditable(), diff --git a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts index 7e8435bbdc65e..5c6d06b5eaeb6 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/visualize/kibana_services.ts @@ -42,7 +42,7 @@ import { timefilter } from 'ui/timefilter'; // Saved objects import { SavedObjectsClientProvider } from 'ui/saved_objects'; // @ts-ignore -import { SavedObjectProvider } from 'ui/saved_objects/saved_object'; +import { SavedObject, SavedObjectProvider } from 'ui/saved_objects/saved_object'; import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_registry'; import { createUiStatsReporter, METRIC_TYPE } from '../../../ui_metric/public'; @@ -105,7 +105,6 @@ export { absoluteToParsedUrl } from 'ui/url/absolute_to_parsed_url'; export { KibanaParsedUrl } from 'ui/url/kibana_parsed_url'; export { migrateLegacyQuery } from 'ui/utils/migrate_legacy_query'; export { subscribeWithScope } from 'ui/utils/subscribe_with_scope'; -export { getVisualizeLoader } from 'ui/visualize/loader'; export { SavedObjectSaveModal } from 'ui/saved_objects/components/saved_object_save_modal'; export { Container, @@ -121,12 +120,8 @@ export { METRIC_TYPE }; export { StaticIndexPattern } from 'ui/index_patterns'; export { AppState } from 'ui/state_management/app_state'; export { VisType } from 'ui/vis'; -export { VisualizeLoader } from 'ui/visualize/loader'; -export { - VisSavedObject, - VisualizeLoaderParams, - VisualizeUpdateParams, -} from 'ui/visualize/loader/types'; // export const export { FeatureCatalogueCategory } from 'ui/registry/feature_catalogue'; + +export { VisSavedObject } from './embeddable/visualize_embeddable'; diff --git a/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js b/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js index fbd70a0d8c0f7..efab03303aa80 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js +++ b/src/legacy/core_plugins/kibana/public/visualize/listing/visualize_listing_table.js @@ -21,13 +21,13 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { TableListView } from './../../table_list_view'; +import { TableListView } from '../../../../../../../src/plugins/kibana_react/public'; import { EuiIcon, EuiBetaBadge, EuiLink, EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { getServices } from '../kibana_services'; -const { capabilities } = getServices(); +const { capabilities, toastNotifications, uiSettings } = getServices(); class VisualizeListingTable extends Component { constructor(props) { @@ -57,6 +57,8 @@ class VisualizeListingTable extends Component { tableListTitle={i18n.translate('kbn.visualize.listing.table.listTitle', { defaultMessage: 'Visualizations', })} + toastNotifications={toastNotifications} + uiSettings={uiSettings} /> ); } diff --git a/src/legacy/core_plugins/tile_map/public/tile_map_visualization.js b/src/legacy/core_plugins/tile_map/public/tile_map_visualization.js index ca798b6bf2470..560a5c93c938c 100644 --- a/src/legacy/core_plugins/tile_map/public/tile_map_visualization.js +++ b/src/legacy/core_plugins/tile_map/public/tile_map_visualization.js @@ -79,6 +79,7 @@ export const createTileMapVisualization = ({ serviceSettings, $injector }) => { return; } if (precisionChange) { + updateGeohashAgg(); this.vis.updateState(); } else { //when we filter queries by collar diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor_visualization.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor_visualization.js index f873cf9c178f8..ae82dc41fa9bc 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor_visualization.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_editor_visualization.js @@ -75,13 +75,13 @@ class VisEditorVisualizationUI extends Component { this._handler = await embeddables.getEmbeddableFactory('visualization').createFromObject(savedObj, { vis: {}, timeRange: timeRange, - filters: appState.filters || [], + filters: appState ? appState.filters || [] : [], }); - this._handler.render(this._visEl.current); + await this._handler.render(this._visEl.current); this._subscription = this._handler.handler.data$.subscribe(data => { - this.setPanelInterval(data.visData); - onDataChange(data); + this.setPanelInterval(data.value.visData); + onDataChange(data.value); }); } diff --git a/src/legacy/ui/public/vis/editors/default/default.html b/src/legacy/ui/public/vis/editors/default/default.html index 3e7a94c77ac42..2a759815f57f2 100644 --- a/src/legacy/ui/public/vis/editors/default/default.html +++ b/src/legacy/ui/public/vis/editors/default/default.html @@ -11,11 +11,6 @@
diff --git a/src/legacy/ui/public/visualize/components/visualization_chart.test.js b/src/legacy/ui/public/visualize/components/visualization_chart.test.js index 280370cdfe995..09d24ab71097a 100644 --- a/src/legacy/ui/public/visualize/components/visualization_chart.test.js +++ b/src/legacy/ui/public/visualize/components/visualization_chart.test.js @@ -57,46 +57,10 @@ describe('', () => { expect(wrapper.text()).toBe('Test Visualization visualization, not yet accessible'); }); - it('should emit render start and render end', async () => { - const renderStart = jest.fn(); - const renderComplete = jest.fn(); - const domNode = document.createElement('div'); - domNode.addEventListener('renderStart', renderStart); - domNode.addEventListener('renderComplete', renderComplete); - - mount(, { - attachTo: domNode - }); - - jest.runAllTimers(); - await renderPromise; - expect(renderStart).toHaveBeenCalledTimes(1); - expect(renderComplete).toHaveBeenCalledTimes(1); - - }); - it('should render visualization', async () => { const wrapper = mount(); jest.runAllTimers(); await renderPromise; expect(wrapper.find('.visChart').text()).toMatch(/markdown/); }); - - it('should re-render on param change', async () => { - const renderComplete = jest.fn(); - const wrapper = mount(); - const domNode = wrapper.getDOMNode(); - domNode.addEventListener('renderComplete', renderComplete); - jest.runAllTimers(); - await renderPromise; - expect(renderComplete).toHaveBeenCalledTimes(1); - - vis.params.markdown = 'new text'; - wrapper.setProps({ vis }); - jest.runAllTimers(); - await renderPromise; - - expect(wrapper.find('.visChart').text()).toBe('new text'); - expect(renderComplete).toHaveBeenCalledTimes(2); - }); }); diff --git a/src/legacy/ui/public/visualize/components/visualization_chart.tsx b/src/legacy/ui/public/visualize/components/visualization_chart.tsx index 06e44a4fd6e1c..eb7f130ec1a54 100644 --- a/src/legacy/ui/public/visualize/components/visualization_chart.tsx +++ b/src/legacy/ui/public/visualize/components/visualization_chart.tsx @@ -19,13 +19,9 @@ import React from 'react'; import * as Rx from 'rxjs'; -import { debounceTime, filter, share, switchMap, tap } from 'rxjs/operators'; +import { debounceTime, filter, share, switchMap } from 'rxjs/operators'; import { PersistedState } from '../../persisted_state'; -import { - dispatchRenderComplete, - dispatchRenderStart, -} from '../../../../../plugins/kibana_utils/public'; import { ResizeChecker } from '../../resize_checker'; import { Vis, VisualizationController } from '../../vis'; import { getUpdateStatus } from '../../vis/update_status'; @@ -59,11 +55,6 @@ class VisualizationChart extends React.Component { const render$ = this.renderSubject.asObservable().pipe(share()); const success$ = render$.pipe( - tap(() => { - if (this.chartDiv.current) { - dispatchRenderStart(this.chartDiv.current); - } - }), filter( ({ vis, visData, container }) => vis && container && (!vis.type.requiresSearch || visData) ), @@ -85,8 +76,8 @@ class VisualizationChart extends React.Component { const requestError$ = render$.pipe(filter(({ vis }) => vis.requestError)); this.renderSubscription = Rx.merge(success$, requestError$).subscribe(() => { - if (this.chartDiv.current !== null) { - dispatchRenderComplete(this.chartDiv.current); + if (this.props.onInit) { + this.props.onInit(); } }); } @@ -111,19 +102,11 @@ class VisualizationChart extends React.Component { throw new Error('chartDiv and currentDiv reference should always be present.'); } - const { vis, onInit } = this.props; + const { vis } = this.props; const Visualization = vis.type.visualization; this.visualization = new Visualization(this.chartDiv.current, vis); - if (onInit) { - // In case the visualization implementation has an isLoaded function, we - // call that and wait for the result to resolve (in case it was a promise). - const visLoaded = - this.visualization && this.visualization.isLoaded && this.visualization.isLoaded(); - Promise.resolve(visLoaded).then(onInit); - } - // We know that containerDiv.current will never be null, since we will always // have rendered and the div is always rendered into the tree (i.e. not // inside any condition). diff --git a/src/legacy/ui/public/visualize/loader/__snapshots__/embedded_visualize_handler.test.ts.snap b/src/legacy/ui/public/visualize/loader/__snapshots__/embedded_visualize_handler.test.ts.snap deleted file mode 100644 index 6650731942e7e..0000000000000 --- a/src/legacy/ui/public/visualize/loader/__snapshots__/embedded_visualize_handler.test.ts.snap +++ /dev/null @@ -1,30 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EmbeddedVisualizeHandler data$ observable can be used to get response data in the correct format 1`] = ` -Object { - "params": Object {}, - "visConfig": Object {}, - "visData": Object {}, - "visType": "histogram", -} -`; - -exports[`EmbeddedVisualizeHandler update should add provided data- attributes to the html element 1`] = ` -
-`; - -exports[`EmbeddedVisualizeHandler update should remove null data- attributes from the html element 1`] = ` -
-`; diff --git a/src/legacy/ui/public/visualize/loader/__tests__/visualization_loader.js b/src/legacy/ui/public/visualize/loader/__tests__/visualization_loader.js deleted file mode 100644 index ffce391fc1a07..0000000000000 --- a/src/legacy/ui/public/visualize/loader/__tests__/visualization_loader.js +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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 $ from 'jquery'; -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; - -import { setupAndTeardownInjectorStub } from 'test_utils/stub_get_active_injector'; - -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -import { VisProvider } from '../../../vis'; -import { visualizationLoader } from '../visualization_loader'; - -describe('visualization loader', () => { - let vis; - - beforeEach(ngMock.module('kibana', 'kibana/directive')); - beforeEach(ngMock.inject((_$rootScope_, savedVisualizations, Private) => { - const indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - - // Create a new Vis object - const Vis = Private(VisProvider); - vis = new Vis(indexPattern, { - type: 'markdown', - params: { markdown: 'this is test' }, - }); - - })); - setupAndTeardownInjectorStub(); - - it('should render visualization', async () => { - const element = document.createElement('div'); - expect(visualizationLoader.render).to.be.a('function'); - visualizationLoader.render(element, vis, null, vis.params); - expect($(element).find('.visualization').length).to.be(1); - }); - - -}); diff --git a/src/legacy/ui/public/visualize/loader/__tests__/visualize_loader.js b/src/legacy/ui/public/visualize/loader/__tests__/visualize_loader.js deleted file mode 100644 index 3fff184ffd199..0000000000000 --- a/src/legacy/ui/public/visualize/loader/__tests__/visualize_loader.js +++ /dev/null @@ -1,478 +0,0 @@ -/* - * 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 angular from 'angular'; -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import sinon from 'sinon'; -import { cloneDeep } from 'lodash'; - -import { setupAndTeardownInjectorStub } from 'test_utils/stub_get_active_injector'; - -import FixturesStubbedSearchSourceProvider from 'fixtures/stubbed_search_source'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; - -import { VisProvider } from '../../../vis'; -import { getVisualizeLoader } from '../visualize_loader'; -import { EmbeddedVisualizeHandler } from '../embedded_visualize_handler'; -import { Inspector } from '../../../inspector/inspector'; -import { dispatchRenderComplete } from '../../../../../../plugins/kibana_utils/public'; -import { PipelineDataLoader } from '../pipeline_data_loader'; -import { PersistedState } from '../../../persisted_state'; -import { DataAdapter, RequestAdapter } from '../../../inspector/adapters'; - -describe('visualize loader', () => { - - let DataLoader; - let searchSource; - let vis; - let $rootScope; - let loader; - let mockedSavedObject; - let sandbox; - - function createSavedObject() { - return { - vis, - searchSource, - }; - } - - async function timeout(delay = 0) { - return new Promise(resolve => { - setTimeout(resolve, delay); - }); - } - - function newContainer() { - return angular.element('
'); - } - - function embedWithParams(params) { - const container = newContainer(); - loader.embedVisualizationWithSavedObject(container[0], createSavedObject(), params); - $rootScope.$digest(); - return container.find('[data-test-subj="visualizationLoader"]'); - } - - beforeEach(ngMock.module('kibana', 'kibana/directive')); - beforeEach(ngMock.inject((_$rootScope_, savedVisualizations, Private) => { - $rootScope = _$rootScope_; - searchSource = Private(FixturesStubbedSearchSourceProvider); - const indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - - DataLoader = PipelineDataLoader; - // Create a new Vis object - const Vis = Private(VisProvider); - vis = new Vis(indexPattern, { - type: 'pie', - title: 'testVis', - params: {}, - aggs: [ - { type: 'count', schema: 'metric' }, - { - type: 'range', - schema: 'bucket', - params: { - field: 'bytes', - ranges: [ - { from: 0, to: 1000 }, - { from: 1000, to: 2000 } - ] - } - } - ] - }); - vis.type.requestHandler = 'courier'; - vis.type.responseHandler = 'none'; - vis.type.requiresSearch = false; - - // Setup savedObject - mockedSavedObject = createSavedObject(); - - sandbox = sinon.sandbox.create(); - // Mock savedVisualizations.get to return 'mockedSavedObject' when id is 'exists' - sandbox.stub(savedVisualizations, 'get').callsFake((id) => - id === 'exists' ? Promise.resolve(mockedSavedObject) : Promise.reject() - ); - })); - setupAndTeardownInjectorStub(); - beforeEach(async () => { - loader = await getVisualizeLoader(); - }); - - afterEach(() => { - if (sandbox) { - sandbox.restore(); - } - }); - - describe('getVisualizeLoader', () => { - - it('should return a promise', () => { - expect(getVisualizeLoader().then).to.be.a('function'); - }); - - it('should resolve to an object', async () => { - const visualizeLoader = await getVisualizeLoader(); - expect(visualizeLoader).to.be.an('object'); - }); - - }); - - describe('service', () => { - - describe('getVisualizationList', () => { - - it('should be a function', async () => { - expect(loader.getVisualizationList).to.be.a('function'); - }); - - }); - - describe('embedVisualizationWithSavedObject', () => { - - it('should be a function', () => { - expect(loader.embedVisualizationWithSavedObject).to.be.a('function'); - }); - - it('should render the visualize element', () => { - const container = newContainer(); - loader.embedVisualizationWithSavedObject(container[0], createSavedObject(), {}); - expect(container.find('[data-test-subj="visualizationLoader"]').length).to.be(1); - }); - - it('should not mutate vis.params', () => { - const container = newContainer(); - const savedObject = createSavedObject(); - const paramsBefore = cloneDeep(vis.params); - loader.embedVisualizationWithSavedObject(container[0], savedObject, {}); - const paramsAfter = cloneDeep(vis.params); - expect(paramsBefore).to.eql(paramsAfter); - }); - - it('should replace content of container by default', () => { - const container = angular.element('
'); - loader.embedVisualizationWithSavedObject(container[0], createSavedObject(), {}); - expect(container.find('#prevContent').length).to.be(0); - }); - - it('should append content to container when using append parameter', () => { - const container = angular.element('
'); - loader.embedVisualizationWithSavedObject(container[0], createSavedObject(), { - append: true - }); - expect(container.children().length).to.be(2); - expect(container.find('#prevContent').length).to.be(1); - }); - - it('should apply css classes from parameters', () => { - const vis = embedWithParams({ cssClass: 'my-css-class another-class' }); - expect(vis.hasClass('my-css-class')).to.be(true); - expect(vis.hasClass('another-class')).to.be(true); - }); - - it('should apply data attributes from dataAttrs parameter', () => { - const vis = embedWithParams({ - dataAttrs: { - 'foo': '', - 'with-dash': 'value', - } - }); - expect(vis.attr('data-foo')).to.be(''); - expect(vis.attr('data-with-dash')).to.be('value'); - }); - }); - - describe('embedVisualizationWithId', () => { - - it('should be a function', async () => { - expect(loader.embedVisualizationWithId).to.be.a('function'); - }); - - it('should reject if the id was not found', () => { - const resolveSpy = sinon.spy(); - const rejectSpy = sinon.spy(); - const container = newContainer(); - return loader.embedVisualizationWithId(container[0], 'not-existing', {}) - .then(resolveSpy, rejectSpy) - .then(() => { - expect(resolveSpy.called).to.be(false); - expect(rejectSpy.calledOnce).to.be(true); - }); - }); - - it('should render a visualize element, if id was found', async () => { - const container = newContainer(); - await loader.embedVisualizationWithId(container[0], 'exists', {}); - expect(container.find('[data-test-subj="visualizationLoader"]').length).to.be(1); - }); - - }); - - describe('EmbeddedVisualizeHandler', () => { - it('should be returned from embedVisualizationWithId via a promise', async () => { - const handler = await loader.embedVisualizationWithId(newContainer()[0], 'exists', {}); - expect(handler instanceof EmbeddedVisualizeHandler).to.be(true); - }); - - it('should be returned from embedVisualizationWithSavedObject', async () => { - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), {}); - expect(handler instanceof EmbeddedVisualizeHandler).to.be(true); - }); - - it('should give access to the visualize element', () => { - const container = newContainer(); - const handler = loader.embedVisualizationWithSavedObject(container[0], createSavedObject(), {}); - expect(handler.getElement()).to.be(container.find('[data-test-subj="visualizationLoader"]')[0]); - }); - - it('should allow opening the inspector of the visualization and return its session', () => { - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), {}); - sandbox.spy(Inspector, 'open'); - const inspectorSession = handler.openInspector(); - expect(Inspector.open.calledOnce).to.be(true); - expect(inspectorSession.close).to.be.a('function'); - inspectorSession.close(); - }); - - describe('inspector', () => { - - describe('hasInspector()', () => { - it('should forward to inspectors hasInspector', () => { - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), {}); - sinon.spy(Inspector, 'isAvailable'); - handler.hasInspector(); - expect(Inspector.isAvailable.calledOnce).to.be(true); - const adapters = Inspector.isAvailable.lastCall.args[0]; - expect(adapters.data).to.be.a(DataAdapter); - expect(adapters.requests).to.be.a(RequestAdapter); - }); - - it('should return hasInspectors result', () => { - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), {}); - const stub = sinon.stub(Inspector, 'isAvailable'); - stub.returns(true); - expect(handler.hasInspector()).to.be(true); - stub.returns(false); - expect(handler.hasInspector()).to.be(false); - }); - - afterEach(() => { - Inspector.isAvailable.restore(); - }); - }); - - describe('openInspector()', () => { - - beforeEach(() => { - sinon.stub(Inspector, 'open'); - }); - - it('should call openInspector with all attached inspectors', () => { - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), {}); - handler.openInspector(); - expect(Inspector.open.calledOnce).to.be(true); - const adapters = Inspector.open.lastCall.args[0]; - expect(adapters).to.be(handler.inspectorAdapters); - }); - - it('should pass the vis title to the openInspector call', () => { - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), {}); - handler.openInspector(); - expect(Inspector.open.calledOnce).to.be(true); - const params = Inspector.open.lastCall.args[1]; - expect(params.title).to.be('testVis'); - }); - - afterEach(() => { - Inspector.open.restore(); - }); - }); - - describe('inspectorAdapters', () => { - - it('should register none for none requestHandler', () => { - const savedObj = createSavedObject(); - savedObj.vis.type.requestHandler = 'none'; - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], savedObj, {}); - expect(handler.inspectorAdapters).to.eql({}); - }); - - it('should attach data and request handler for courier', () => { - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), {}); - expect(handler.inspectorAdapters.data).to.be.a(DataAdapter); - expect(handler.inspectorAdapters.requests).to.be.a(RequestAdapter); - }); - - it('should allow enabling data adapter manually', () => { - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), {}); - expect(handler.inspectorAdapters.data).to.be.a(DataAdapter); - }); - - it('should allow enabling requests adapter manually', () => { - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), {}); - expect(handler.inspectorAdapters.requests).to.be.a(RequestAdapter); - }); - - it('should allow adding custom inspector adapters via the custom key', () => { - const Foodapter = class { }; - const Bardapter = class { }; - const savedObj = createSavedObject(); - savedObj.vis.type.inspectorAdapters = { - custom: { foo: Foodapter, bar: Bardapter } - }; - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], savedObj, {}); - expect(handler.inspectorAdapters.foo).to.be.a(Foodapter); - expect(handler.inspectorAdapters.bar).to.be.a(Bardapter); - }); - - it('should not share adapter instances between vis instances', () => { - const Foodapter = class { }; - const savedObj1 = createSavedObject(); - const savedObj2 = createSavedObject(); - savedObj1.vis.type.inspectorAdapters = { custom: { foo: Foodapter } }; - savedObj2.vis.type.inspectorAdapters = { custom: { foo: Foodapter } }; - const handler1 = loader.embedVisualizationWithSavedObject(newContainer()[0], savedObj1, {}); - const handler2 = loader.embedVisualizationWithSavedObject(newContainer()[0], savedObj2, {}); - expect(handler1.inspectorAdapters.foo).to.be.a(Foodapter); - expect(handler2.inspectorAdapters.foo).to.be.a(Foodapter); - expect(handler1.inspectorAdapters.foo).not.to.be(handler2.inspectorAdapters.foo); - expect(handler1.inspectorAdapters.data).to.be.a(DataAdapter); - expect(handler2.inspectorAdapters.data).to.be.a(DataAdapter); - expect(handler1.inspectorAdapters.data).not.to.be(handler2.inspectorAdapters.data); - }); - }); - - }); - - it('should have whenFirstRenderComplete returns a promise resolving on first renderComplete event', async () => { - const container = newContainer(); - const handler = loader.embedVisualizationWithSavedObject(container[0], createSavedObject(), {}); - const spy = sinon.spy(); - handler.whenFirstRenderComplete().then(spy); - expect(spy.notCalled).to.be(true); - dispatchRenderComplete(container.find('[data-test-subj="visualizationLoader"]')[0]); - await timeout(); - expect(spy.calledOnce).to.be(true); - }); - - it('should add listeners via addRenderCompleteListener that triggers on renderComplete events', async () => { - const container = newContainer(); - const handler = loader.embedVisualizationWithSavedObject(container[0], createSavedObject(), {}); - const spy = sinon.spy(); - handler.addRenderCompleteListener(spy); - expect(spy.notCalled).to.be(true); - dispatchRenderComplete(container.find('[data-test-subj="visualizationLoader"]')[0]); - await timeout(); - expect(spy.calledOnce).to.be(true); - }); - - it('should call render complete listeners once per renderComplete event', async () => { - const container = newContainer(); - const handler = loader.embedVisualizationWithSavedObject(container[0], createSavedObject(), {}); - const spy = sinon.spy(); - handler.addRenderCompleteListener(spy); - expect(spy.notCalled).to.be(true); - dispatchRenderComplete(container.find('[data-test-subj="visualizationLoader"]')[0]); - dispatchRenderComplete(container.find('[data-test-subj="visualizationLoader"]')[0]); - dispatchRenderComplete(container.find('[data-test-subj="visualizationLoader"]')[0]); - expect(spy.callCount).to.be(3); - }); - - it('should successfully remove listeners from render complete', async () => { - const container = newContainer(); - const handler = loader.embedVisualizationWithSavedObject(container[0], createSavedObject(), {}); - const spy = sinon.spy(); - handler.addRenderCompleteListener(spy); - expect(spy.notCalled).to.be(true); - dispatchRenderComplete(container.find('[data-test-subj="visualizationLoader"]')[0]); - expect(spy.calledOnce).to.be(true); - spy.resetHistory(); - handler.removeRenderCompleteListener(spy); - dispatchRenderComplete(container.find('[data-test-subj="visualizationLoader"]')[0]); - expect(spy.notCalled).to.be(true); - }); - - - it('should allow updating and deleting data attributes', () => { - const container = newContainer(); - const handler = loader.embedVisualizationWithSavedObject(container[0], createSavedObject(), { - dataAttrs: { - foo: 42 - } - }); - expect(container.find('[data-test-subj="visualizationLoader"]').attr('data-foo')).to.be('42'); - handler.update({ - dataAttrs: { - foo: null, - added: 'value', - } - }); - expect(container.find('[data-test-subj="visualizationLoader"]')[0].hasAttribute('data-foo')).to.be(false); - expect(container.find('[data-test-subj="visualizationLoader"]').attr('data-added')).to.be('value'); - }); - - it('should allow updating the time range of the visualization', async () => { - const spy = sandbox.spy(DataLoader.prototype, 'fetch'); - - const handler = loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), { - timeRange: { from: 'now-7d', to: 'now' } - }); - - // Wait for the initial fetch and render to happen - await timeout(150); - spy.resetHistory(); - - handler.update({ - timeRange: { from: 'now-10d/d', to: 'now' } - }); - - // Wait for fetch debounce to happen (as soon as we use lodash 4+ we could use fake timers here for the debounce) - await timeout(150); - - sinon.assert.calledOnce(spy); - sinon.assert.calledWith(spy, sinon.match({ timeRange: { from: 'now-10d/d', to: 'now' } })); - }); - - it('should not set forceFetch on uiState change', async () => { - const spy = sandbox.spy(DataLoader.prototype, 'fetch'); - - const uiState = new PersistedState(); - loader.embedVisualizationWithSavedObject(newContainer()[0], createSavedObject(), { - timeRange: { from: 'now-7d', to: 'now' }, - uiState: uiState, - }); - - // Wait for the initial fetch and render to happen - await timeout(150); - spy.resetHistory(); - - uiState.set('property', 'value'); - - // Wait for fetch debounce to happen (as soon as we use lodash 4+ we could use fake timers here for the debounce) - await timeout(150); - - sinon.assert.calledOnce(spy); - sinon.assert.calledWith(spy, sinon.match({ forceFetch: false })); - }); - }); - - }); -}); diff --git a/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.test.mocks.ts b/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.test.mocks.ts deleted file mode 100644 index 4ca90d6c6b61b..0000000000000 --- a/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.test.mocks.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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. - */ - -jest.useFakeTimers(); - -import { Subject } from 'rxjs'; - -jest.mock('ui/notify', () => ({ - toastNotifications: jest.fn(), -})); - -jest.mock('./utils', () => ({ - queryGeohashBounds: jest.fn(), -})); - -jest.mock('./pipeline_helpers/utilities', () => ({ - getFormat: jest.fn(), - getTableAggs: jest.fn(), -})); - -const autoRefreshFetchSub = new Subject(); - -export const timefilter = { - _triggerAutoRefresh: () => { - autoRefreshFetchSub.next(); - }, - getAutoRefreshFetch$: () => { - return autoRefreshFetchSub.asObservable(); - }, -}; -jest.doMock('../../timefilter', () => ({ timefilter })); - -jest.mock('../../inspector', () => ({ - Inspector: { - open: jest.fn(), - isAvailable: jest.fn(), - }, -})); - -export const mockDataLoaderFetch = jest.fn().mockReturnValue({ - as: 'visualization', - value: { - visType: 'histogram', - visData: {}, - visConfig: {}, - params: {}, - }, -}); -const MockDataLoader = class { - public async fetch(data: any) { - return await mockDataLoaderFetch(data); - } -}; - -jest.mock('./pipeline_data_loader', () => ({ - PipelineDataLoader: MockDataLoader, -})); diff --git a/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.test.ts b/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.test.ts deleted file mode 100644 index c73f787457a03..0000000000000 --- a/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -/* - * 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. - */ -jest.mock('ui/new_platform'); - -import { searchSourceMock } from '../../courier/search_source/mocks'; -import { mockDataLoaderFetch, timefilter } from './embedded_visualize_handler.test.mocks'; - -import _ from 'lodash'; -// @ts-ignore -import MockState from '../../../../../fixtures/mock_state'; -import { Vis } from '../../vis'; -import { VisResponseData } from './types'; -import { Inspector } from '../../inspector'; -import { EmbeddedVisualizeHandler, RequestHandlerParams } from './embedded_visualize_handler'; -import { AggConfigs } from 'ui/agg_types/agg_configs'; - -jest.mock('plugins/interpreter/interpreter', () => ({ - getInterpreter: () => { - return Promise.resolve(); - }, -})); - -jest.mock('../../../../core_plugins/interpreter/public/registries', () => ({ - registries: { - renderers: { - get: (name: string) => { - return { - render: async () => { - return {}; - }, - }; - }, - }, - }, -})); - -describe('EmbeddedVisualizeHandler', () => { - let handler: any; - let div: HTMLElement; - let dataLoaderParams: RequestHandlerParams; - const mockVis: Vis = { - title: 'My Vis', - // @ts-ignore - type: 'foo', - getAggConfig: () => [], - _setUiState: () => ({}), - getUiState: () => new MockState(), - on: () => ({}), - off: () => ({}), - removeListener: jest.fn(), - API: {}, - }; - - beforeEach(() => { - jest.clearAllMocks(); - - jest.spyOn(_, 'debounce').mockImplementation( - // @ts-ignore - (f: Function) => { - // @ts-ignore - f.cancel = () => {}; - return f; - } - ); - - dataLoaderParams = { - aggs: ([] as any) as AggConfigs, - filters: undefined, - forceFetch: false, - inspectorAdapters: {}, - query: undefined, - queryFilter: null, - searchSource: searchSourceMock, - timeRange: undefined, - uiState: undefined, - }; - - div = document.createElement('div'); - handler = new EmbeddedVisualizeHandler( - div, - { - vis: mockVis, - title: 'My Vis', - searchSource: searchSourceMock, - destroy: () => ({}), - copyOnSave: false, - save: () => Promise.resolve('123'), - }, - { - autoFetch: true, - Private: (provider: () => T) => provider(), - queryFilter: null, - } - ); - }); - - afterEach(() => { - handler.destroy(); - }); - - describe('autoFetch', () => { - it('should trigger a reload when autoFetch=true and auto refresh happens', () => { - const spy = jest.spyOn(handler, 'fetchAndRender'); - timefilter._triggerAutoRefresh(); - jest.runAllTimers(); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(true); - }); - - it('should not trigger a reload when autoFetch=false and auto refresh happens', () => { - handler = new EmbeddedVisualizeHandler( - div, - { - vis: mockVis, - title: 'My Vis', - searchSource: searchSourceMock, - destroy: () => ({}), - copyOnSave: false, - save: () => Promise.resolve('123'), - }, - { - autoFetch: false, - Private: (provider: () => T) => provider(), - queryFilter: null, - } - ); - const spy = jest.spyOn(handler, 'fetchAndRender'); - timefilter._triggerAutoRefresh(); - jest.runAllTimers(); - expect(spy).not.toHaveBeenCalled(); - }); - }); - - describe('getElement', () => { - it('should return the provided html element', () => { - expect(handler.getElement()).toBe(div); - }); - }); - - describe('update', () => { - it('should add provided data- attributes to the html element', () => { - const spy = jest.spyOn(handler, 'fetchAndRender'); - const params = { - dataAttrs: { foo: 'bar' }, - }; - handler.update(params); - expect(spy).not.toHaveBeenCalled(); - expect(handler.getElement()).toMatchSnapshot(); - }); - - it('should remove null data- attributes from the html element', () => { - const spy = jest.spyOn(handler, 'fetchAndRender'); - handler.update({ - dataAttrs: { foo: 'bar' }, - }); - const params = { - dataAttrs: { - foo: null, - baz: 'qux', - }, - }; - handler.update(params); - expect(spy).not.toHaveBeenCalled(); - expect(handler.getElement()).toMatchSnapshot(); - }); - - it('should call dataLoader.render with updated timeRange', () => { - const params = { timeRange: { foo: 'bar' } }; - handler.update(params); - expect(mockDataLoaderFetch).toHaveBeenCalled(); - const callIndex = mockDataLoaderFetch.mock.calls.length - 1; - const { abortSignal, ...otherParams } = mockDataLoaderFetch.mock.calls[callIndex][0]; - expect(abortSignal).toBeInstanceOf(AbortSignal); - expect(otherParams).toEqual({ ...dataLoaderParams, ...params }); - }); - - it('should call dataLoader.render with updated filters', () => { - const params = { filters: [{ meta: { disabled: false } }] }; - handler.update(params); - expect(mockDataLoaderFetch).toHaveBeenCalled(); - const callIndex = mockDataLoaderFetch.mock.calls.length - 1; - const { abortSignal, ...otherParams } = mockDataLoaderFetch.mock.calls[callIndex][0]; - expect(abortSignal).toBeInstanceOf(AbortSignal); - expect(otherParams).toEqual({ ...dataLoaderParams, ...params }); - }); - - it('should call dataLoader.render with updated query', () => { - const params = { query: { foo: 'bar' } }; - handler.update(params); - expect(mockDataLoaderFetch).toHaveBeenCalled(); - const callIndex = mockDataLoaderFetch.mock.calls.length - 1; - const { abortSignal, ...otherParams } = mockDataLoaderFetch.mock.calls[callIndex][0]; - expect(abortSignal).toBeInstanceOf(AbortSignal); - expect(otherParams).toEqual({ ...dataLoaderParams, ...params }); - }); - }); - - describe('destroy', () => { - it('should remove vis event listeners', () => { - const spy = jest.spyOn(mockVis, 'removeListener'); - handler.destroy(); - expect(spy).toHaveBeenCalledTimes(2); - expect(spy.mock.calls[0][0]).toBe('reload'); - expect(spy.mock.calls[1][0]).toBe('update'); - }); - - it('should remove element event listeners', () => { - const spy = jest.spyOn(handler.getElement(), 'removeEventListener'); - handler.destroy(); - expect(spy).toHaveBeenCalled(); - }); - - it('should prevent subsequent renders', () => { - const spy = jest.spyOn(handler, 'fetchAndRender'); - handler.destroy(); - expect(spy).not.toHaveBeenCalled(); - }); - - it('should cancel debounced fetchAndRender', () => { - const spy = jest.spyOn(handler.debouncedFetchAndRender, 'cancel'); - handler.destroy(); - expect(spy).toHaveBeenCalledTimes(1); - }); - - it('should call abort on controller', () => { - handler.abortController = new AbortController(); - const spy = jest.spyOn(handler.abortController, 'abort'); - handler.destroy(); - expect(spy).toHaveBeenCalled(); - }); - }); - - describe('openInspector', () => { - it('calls Inspector.open()', () => { - handler.openInspector(); - expect(Inspector.open).toHaveBeenCalledTimes(1); - expect(Inspector.open).toHaveBeenCalledWith({}, { title: 'My Vis' }); - }); - }); - - describe('hasInspector', () => { - it('calls Inspector.isAvailable()', () => { - handler.hasInspector(); - expect(Inspector.isAvailable).toHaveBeenCalledTimes(1); - expect(Inspector.isAvailable).toHaveBeenCalledWith({}); - }); - }); - - describe('reload', () => { - it('should force fetch and render', () => { - const spy = jest.spyOn(handler, 'fetchAndRender'); - handler.reload(); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(true); - }); - }); - - describe('data$', () => { - it('observable can be used to get response data in the correct format', async () => { - let response; - handler.data$.subscribe((data: VisResponseData) => (response = data)); - await handler.fetch(true); - jest.runAllTimers(); - expect(response).toMatchSnapshot(); - }); - }); - - describe('render', () => { - // TODO - }); - - describe('whenFirstRenderComplete', () => { - // TODO - }); - - describe('addRenderCompleteListener', () => { - // TODO - }); - - describe('removeRenderCompleteListener', () => { - // TODO - }); -}); diff --git a/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.ts b/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.ts deleted file mode 100644 index fb16e095b3418..0000000000000 --- a/src/legacy/ui/public/visualize/loader/embedded_visualize_handler.ts +++ /dev/null @@ -1,553 +0,0 @@ -/* - * 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 { EventEmitter } from 'events'; -import { debounce, forEach, get, isEqual } from 'lodash'; -import * as Rx from 'rxjs'; -import { share } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; -import { toastNotifications } from 'ui/notify'; -// @ts-ignore untyped dependency -import { AggConfigs } from 'ui/agg_types/agg_configs'; -import { SearchSource } from 'ui/courier'; -import { QueryFilter } from 'ui/filter_manager/query_filter'; - -import { TimeRange, onlyDisabledFiltersChanged } from '../../../../../plugins/data/public'; -import { registries } from '../../../../core_plugins/interpreter/public/registries'; -import { Inspector } from '../../inspector'; -import { Adapters } from '../../inspector/types'; -import { PersistedState } from '../../persisted_state'; -import { IPrivate } from '../../private'; -import { RenderCompleteHelper } from '../../../../../plugins/kibana_utils/public'; -import { AppState } from '../../state_management/app_state'; -import { timefilter } from '../../timefilter'; -import { Vis } from '../../vis'; -// @ts-ignore untyped dependency -import { VisFiltersProvider } from '../../vis/vis_filters'; -import { PipelineDataLoader } from './pipeline_data_loader'; -import { visualizationLoader } from './visualization_loader'; -import { Query } from '../../../../core_plugins/data/public'; -import { esFilters } from '../../../../../plugins/data/public'; - -import { DataAdapter, RequestAdapter } from '../../inspector/adapters'; - -import { getTableAggs } from './pipeline_helpers/utilities'; -import { - VisResponseData, - VisSavedObject, - VisualizeLoaderParams, - VisualizeUpdateParams, -} from './types'; -import { queryGeohashBounds } from './utils'; - -interface EmbeddedVisualizeHandlerParams extends VisualizeLoaderParams { - Private: IPrivate; - queryFilter: any; - autoFetch?: boolean; -} - -export interface RequestHandlerParams { - searchSource: SearchSource; - aggs: AggConfigs; - timeRange?: TimeRange; - query?: Query; - filters?: esFilters.Filter[]; - forceFetch: boolean; - queryFilter: QueryFilter; - uiState?: PersistedState; - partialRows?: boolean; - inspectorAdapters: Adapters; - metricsAtAllLevels?: boolean; - visParams?: any; - abortSignal?: AbortSignal; -} - -const RENDER_COMPLETE_EVENT = 'render_complete'; -const DATA_SHARED_ITEM = 'data-shared-item'; -const LOADING_ATTRIBUTE = 'data-loading'; -const RENDERING_COUNT_ATTRIBUTE = 'data-rendering-count'; - -/** - * A handler to the embedded visualization. It offers several methods to interact - * with the visualization. - */ -export class EmbeddedVisualizeHandler { - /** - * This observable will emit every time new data is loaded for the - * visualization. The emitted value is the loaded data after it has - * been transformed by the visualization's response handler. - * This should not be used by any plugin. - * @ignore - */ - public readonly data$: Rx.Observable; - public readonly inspectorAdapters: Adapters = {}; - private vis: Vis; - private handlers: any; - private loaded: boolean = false; - private destroyed: boolean = false; - - private listeners = new EventEmitter(); - private firstRenderComplete: Promise; - private renderCompleteHelper: RenderCompleteHelper; - private shouldForceNextFetch: boolean = false; - private debouncedFetchAndRender = debounce(() => { - if (this.destroyed) { - return; - } - - const forceFetch = this.shouldForceNextFetch; - this.shouldForceNextFetch = false; - this.fetch(forceFetch).then(this.render); - }, 100); - - private dataLoaderParams: RequestHandlerParams; - private readonly appState?: AppState; - private uiState: PersistedState; - private dataLoader: PipelineDataLoader; - private dataSubject: Rx.Subject; - private actions: any = {}; - private events$: Rx.Observable; - private autoFetch: boolean; - private abortController?: AbortController; - private autoRefreshFetchSubscription: Rx.Subscription | undefined; - - constructor( - private readonly element: HTMLElement, - savedObject: VisSavedObject, - params: EmbeddedVisualizeHandlerParams - ) { - const { searchSource, vis } = savedObject; - - const { - appState, - uiState, - queryFilter, - timeRange, - filters, - query, - autoFetch = true, - Private, - } = params; - - this.dataLoaderParams = { - searchSource, - timeRange, - query, - queryFilter, - filters, - uiState, - aggs: vis.getAggConfig(), - forceFetch: false, - inspectorAdapters: this.inspectorAdapters, - }; - - // Listen to the first RENDER_COMPLETE_EVENT to resolve this promise - this.firstRenderComplete = new Promise(resolve => { - this.listeners.once(RENDER_COMPLETE_EVENT, resolve); - }); - - element.setAttribute(LOADING_ATTRIBUTE, ''); - element.setAttribute(DATA_SHARED_ITEM, ''); - element.setAttribute(RENDERING_COUNT_ATTRIBUTE, '0'); - - element.addEventListener('renderComplete', this.onRenderCompleteListener); - - this.autoFetch = autoFetch; - this.appState = appState; - this.vis = vis; - if (uiState) { - vis._setUiState(uiState); - } - this.uiState = this.vis.getUiState(); - - this.handlers = { - vis: this.vis, - uiState: this.uiState, - onDestroy: (fn: () => never) => (this.handlers.destroyFn = fn), - }; - - this.vis.on('update', this.handleVisUpdate); - this.vis.on('reload', this.reload); - this.uiState.on('change', this.onUiStateChange); - if (autoFetch) { - this.autoRefreshFetchSubscription = timefilter.getAutoRefreshFetch$().subscribe(this.reload); - } - - // This is a hack to give maps visualizations access to data in the - // globalState, since they can no longer access it via searchSource. - // TODO: Remove this as a part of elastic/kibana#30593 - this.vis.API.getGeohashBounds = () => { - return queryGeohashBounds(this.vis, { - filters: this.dataLoaderParams.filters, - query: this.dataLoaderParams.query, - }); - }; - - this.dataLoader = new PipelineDataLoader(vis); - const visFilters: any = Private(VisFiltersProvider); - this.renderCompleteHelper = new RenderCompleteHelper(element); - this.inspectorAdapters = this.getActiveInspectorAdapters(); - this.vis.openInspector = this.openInspector; - this.vis.hasInspector = this.hasInspector; - - // init default actions - forEach(this.vis.type.events, (event, eventName) => { - if (event.disabled || !eventName) { - return; - } else { - this.actions[eventName] = event.defaultAction; - } - }); - - this.handlers.eventsSubject = new Rx.Subject(); - this.vis.eventsSubject = this.handlers.eventsSubject; - this.events$ = this.handlers.eventsSubject.asObservable().pipe(share()); - this.events$.subscribe(event => { - if (this.actions[event.name]) { - event.data.aggConfigs = getTableAggs(this.vis); - const newFilters = this.actions[event.name](event.data) || []; - if (event.name === 'brush') { - const fieldName = newFilters[0].meta.key; - const $state = this.vis.API.getAppState(); - const existingFilter = $state.filters.find( - (filter: any) => filter.meta && filter.meta.key === fieldName - ); - if (existingFilter) { - Object.assign(existingFilter, newFilters[0]); - } - } - visFilters.pushFilters(newFilters); - } - }); - - this.dataSubject = new Rx.Subject(); - this.data$ = this.dataSubject.asObservable().pipe(share()); - - this.render(); - } - - /** - * Update properties of the embedded visualization. This method does not allow - * updating all initial parameters, but only a subset of the ones allowed - * in {@link VisualizeUpdateParams}. - * - * @param params The parameters that should be updated. - */ - public update(params: VisualizeUpdateParams = {}) { - // Apply data- attributes to the element if specified - const dataAttrs = params.dataAttrs; - if (dataAttrs) { - Object.keys(dataAttrs).forEach(key => { - if (dataAttrs[key] === null) { - this.element.removeAttribute(`data-${key}`); - return; - } - - this.element.setAttribute(`data-${key}`, dataAttrs[key]); - }); - } - - let fetchRequired = false; - if ( - params.hasOwnProperty('timeRange') && - !isEqual(this.dataLoaderParams.timeRange, params.timeRange) - ) { - fetchRequired = true; - this.dataLoaderParams.timeRange = params.timeRange; - } - if ( - params.hasOwnProperty('filters') && - !onlyDisabledFiltersChanged(this.dataLoaderParams.filters, params.filters) - ) { - fetchRequired = true; - this.dataLoaderParams.filters = params.filters; - } - if (params.hasOwnProperty('query') && !isEqual(this.dataLoaderParams.query, params.query)) { - fetchRequired = true; - this.dataLoaderParams.query = params.query; - } - - if (fetchRequired) { - this.fetchAndRender(); - } - } - - /** - * Destroy the underlying Angular scope of the visualization. This should be - * called whenever you remove the visualization. - */ - public destroy(): void { - this.destroyed = true; - this.cancel(); - this.debouncedFetchAndRender.cancel(); - if (this.autoFetch) { - if (this.autoRefreshFetchSubscription) this.autoRefreshFetchSubscription.unsubscribe(); - } - this.vis.removeListener('reload', this.reload); - this.vis.removeListener('update', this.handleVisUpdate); - this.element.removeEventListener('renderComplete', this.onRenderCompleteListener); - this.uiState.off('change', this.onUiStateChange); - visualizationLoader.destroy(this.element); - this.renderCompleteHelper.destroy(); - if (this.handlers.destroyFn) { - this.handlers.destroyFn(); - } - } - - /** - * Return the actual DOM element (wrapped in jQuery) of the rendered visualization. - * This is especially useful if you used `append: true` in the parameters where - * the visualization will be appended to the specified container. - */ - public getElement(): HTMLElement { - return this.element; - } - - /** - * renders visualization with provided data - * @param response: visualization data - */ - public render = (response: VisResponseData | null = null): void => { - const executeRenderer = this.rendererProvider(response); - if (!executeRenderer) { - return; - } - - // TODO: we have this weird situation when we need to render first, - // and then we call fetch and render... we need to get rid of that. - executeRenderer().then(() => { - if (!this.loaded) { - this.loaded = true; - if (this.autoFetch) { - this.fetchAndRender(); - } - } - }); - }; - - /** - * Opens the inspector for the embedded visualization. This will return an - * handler to the inspector to close and interact with it. - * @return An inspector session to interact with the opened inspector. - */ - public openInspector = () => { - return Inspector.open(this.inspectorAdapters, { - title: this.vis.title, - }); - }; - - public hasInspector = () => { - return Inspector.isAvailable(this.inspectorAdapters); - }; - - /** - * Returns a promise, that will resolve (without a value) once the first rendering of - * the visualization has finished. If you want to listen to consecutive rendering - * events, look into the `addRenderCompleteListener` method. - * - * @returns Promise, that resolves as soon as the visualization is done rendering - * for the first time. - */ - public whenFirstRenderComplete(): Promise { - return this.firstRenderComplete; - } - - /** - * Adds a listener to be called whenever the visualization finished rendering. - * This can be called multiple times, when the visualization rerenders, e.g. due - * to new data. - * - * @param {function} listener The listener to be notified about complete renders. - */ - public addRenderCompleteListener(listener: () => void) { - this.listeners.addListener(RENDER_COMPLETE_EVENT, listener); - } - - /** - * Removes a previously registered render complete listener from this handler. - * This listener will no longer be called when the visualization finished rendering. - * - * @param {function} listener The listener to remove from this handler. - */ - public removeRenderCompleteListener(listener: () => void) { - this.listeners.removeListener(RENDER_COMPLETE_EVENT, listener); - } - - /** - * Force the fetch of new data and renders the chart again. - */ - public reload = () => { - this.fetchAndRender(true); - }; - - private incrementRenderingCount = () => { - const renderingCount = Number(this.element.getAttribute(RENDERING_COUNT_ATTRIBUTE) || 0); - this.element.setAttribute(RENDERING_COUNT_ATTRIBUTE, `${renderingCount + 1}`); - }; - - private onRenderCompleteListener = () => { - this.listeners.emit(RENDER_COMPLETE_EVENT); - this.element.removeAttribute(LOADING_ATTRIBUTE); - this.incrementRenderingCount(); - }; - - private onUiStateChange = () => { - this.fetchAndRender(); - }; - - /** - * Returns an object of all inspectors for this vis object. - * This must only be called after this.type has properly be initialized, - * since we need to read out data from the the vis type to check which - * inspectors are available. - */ - private getActiveInspectorAdapters = (): Adapters => { - const adapters: Adapters = {}; - const { inspectorAdapters: typeAdapters } = this.vis.type; - - // Add the requests inspector adapters if the vis type explicitly requested it via - // inspectorAdapters.requests: true in its definition or if it's using the courier - // request handler, since that will automatically log its requests. - if ((typeAdapters && typeAdapters.requests) || this.vis.type.requestHandler === 'courier') { - adapters.requests = new RequestAdapter(); - } - - // Add the data inspector adapter if the vis type requested it or if the - // vis is using courier, since we know that courier supports logging - // its data. - if ((typeAdapters && typeAdapters.data) || this.vis.type.requestHandler === 'courier') { - adapters.data = new DataAdapter(); - } - - // Add all inspectors, that are explicitly registered with this vis type - if (typeAdapters && typeAdapters.custom) { - Object.entries(typeAdapters.custom).forEach(([key, Adapter]) => { - adapters[key] = new (Adapter as any)(); - }); - } - - return adapters; - }; - - /** - * Fetches new data and renders the chart. This will happen debounced for a couple - * of milliseconds, to bundle fast successive calls into one fetch and render, - * e.g. while resizing the window, this will be triggered constantly on the resize - * event. - * - * @param forceFetch=false Whether the request handler should be signaled to forceFetch - * (i.e. ignore caching in case it supports it). If at least one call to this - * passed `true` the debounced fetch and render will be a force fetch. - */ - private fetchAndRender = (forceFetch = false): void => { - this.shouldForceNextFetch = forceFetch || this.shouldForceNextFetch; - this.element.setAttribute(LOADING_ATTRIBUTE, ''); - this.debouncedFetchAndRender(); - }; - - private handleVisUpdate = () => { - if (this.appState) { - this.appState.vis = this.vis.getState(); - this.appState.save(); - } - - this.fetchAndRender(); - }; - - private cancel = () => { - if (this.abortController) this.abortController.abort(); - }; - - private fetch = (forceFetch: boolean = false) => { - this.cancel(); - this.abortController = new AbortController(); - this.dataLoaderParams.abortSignal = this.abortController.signal; - this.dataLoaderParams.aggs = this.vis.getAggConfig(); - this.dataLoaderParams.forceFetch = forceFetch; - this.dataLoaderParams.inspectorAdapters = this.inspectorAdapters; - - this.vis.filters = { timeRange: this.dataLoaderParams.timeRange }; - this.vis.requestError = undefined; - this.vis.showRequestError = false; - - return ( - this.dataLoader - // Don't pass in this.dataLoaderParams directly because it may be modified async in another - // call to fetch before the previous one has completed - .fetch({ ...this.dataLoaderParams }) - .then(data => { - // Pipeline responses never throw errors, so we need to check for - // `type: 'error'`, and then throw so it can be caught below. - // TODO: We should revisit this after we have fully migrated - // to the new expression pipeline infrastructure. - if (data && data.type === 'error') { - throw data.error; - } - - if (data && data.value) { - this.dataSubject.next(data.value); - } - return data; - }) - .catch(this.handleDataLoaderError) - ); - }; - - /** - * When dataLoader returns an error, we need to make sure it surfaces in the UI. - * - * TODO: Eventually we should add some custom error messages for issues that are - * frequently encountered by users. - */ - private handleDataLoaderError = (error: any): void => { - // If the data loader was aborted then no need to surface this error in the UI - if (error && error.name === 'AbortError') return; - - // Cancel execution of pipeline expressions - if (this.abortController) { - this.abortController.abort(); - } - - this.vis.requestError = error; - this.vis.showRequestError = - error.type && ['NO_OP_SEARCH_STRATEGY', 'UNSUPPORTED_QUERY'].includes(error.type); - - toastNotifications.addDanger({ - title: i18n.translate('common.ui.visualize.dataLoaderError', { - defaultMessage: 'Error in visualization', - }), - text: error.message, - }); - }; - - private rendererProvider = (response: VisResponseData | null) => { - const renderer = registries.renderers.get(get(response || {}, 'as', 'visualization')); - - if (!renderer) { - return null; - } - - return () => - renderer.render( - this.element, - get(response, 'value', { visType: this.vis.type.name }), - this.handlers - ); - }; -} diff --git a/src/legacy/ui/public/visualize/loader/index.ts b/src/legacy/ui/public/visualize/loader/index.ts deleted file mode 100644 index 0ebe8e3a2300f..0000000000000 --- a/src/legacy/ui/public/visualize/loader/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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. - */ - -export * from './visualize_loader'; diff --git a/src/legacy/ui/public/visualize/loader/pipeline_data_loader.ts b/src/legacy/ui/public/visualize/loader/pipeline_data_loader.ts deleted file mode 100644 index c1aa6903abe88..0000000000000 --- a/src/legacy/ui/public/visualize/loader/pipeline_data_loader.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 { Vis } from '../../vis'; -import { buildPipeline, runPipeline } from './pipeline_helpers'; -import { RequestHandlerParams } from './embedded_visualize_handler'; - -export class PipelineDataLoader { - constructor(private readonly vis: Vis) {} - - public async fetch(params: RequestHandlerParams): Promise { - this.vis.pipelineExpression = await buildPipeline(this.vis, params); - - return runPipeline( - this.vis.pipelineExpression, - { type: 'null' }, - { - getInitialContext: () => ({ - type: 'kibana_context', - query: params.query, - timeRange: params.timeRange, - filters: params.filters - ? params.filters.filter(filter => !filter.meta.disabled) - : undefined, - }), - inspectorAdapters: params.inspectorAdapters, - abortSignal: params.abortSignal, - } - ); - } -} diff --git a/src/legacy/ui/public/visualize/loader/pipeline_helpers/index.ts b/src/legacy/ui/public/visualize/loader/pipeline_helpers/index.ts index 69c29339a8713..a1292c59ac61d 100644 --- a/src/legacy/ui/public/visualize/loader/pipeline_helpers/index.ts +++ b/src/legacy/ui/public/visualize/loader/pipeline_helpers/index.ts @@ -18,4 +18,3 @@ */ export { buildPipeline } from './build_pipeline'; -export { runPipeline } from './run_pipeline'; diff --git a/src/legacy/ui/public/visualize/loader/pipeline_helpers/run_pipeline.ts b/src/legacy/ui/public/visualize/loader/pipeline_helpers/run_pipeline.ts deleted file mode 100644 index 78a959b2b0f71..0000000000000 --- a/src/legacy/ui/public/visualize/loader/pipeline_helpers/run_pipeline.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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. - */ - -// @ts-ignore -import { fromExpression } from '@kbn/interpreter/common'; -import { Adapters } from 'ui/inspector'; -import { getInterpreter } from '../../../../../core_plugins/interpreter/public/interpreter'; -import { KibanaContext } from '../../../../../core_plugins/interpreter/public'; - -type getInitialContextFunction = () => KibanaContext; - -export interface RunPipelineHandlers { - getInitialContext: getInitialContextFunction; - inspectorAdapters?: Adapters; - abortSignal?: AbortSignal; -} - -export const runPipeline = async ( - expression: string, - context: any, - handlers: RunPipelineHandlers -) => { - const ast = fromExpression(expression); - const { interpreter } = await getInterpreter(); - const pipelineResponse = await interpreter.interpretAst(ast, context, handlers as any); - return pipelineResponse; -}; diff --git a/src/legacy/ui/public/visualize/loader/types.ts b/src/legacy/ui/public/visualize/loader/types.ts deleted file mode 100644 index 525ec35834ecd..0000000000000 --- a/src/legacy/ui/public/visualize/loader/types.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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 { TimeRange } from 'src/plugins/data/public'; -import { Query } from 'src/legacy/core_plugins/data/public'; -import { SavedObject } from 'ui/saved_objects/saved_object'; -import { VisResponseValue } from 'src/plugins/visualizations/public'; -import { SearchSource } from '../../courier'; -import { PersistedState } from '../../persisted_state'; -import { AppState } from '../../state_management/app_state'; -import { Vis } from '../../vis'; -import { esFilters } from '../../../../../plugins/data/public'; - -export interface VisSavedObject extends SavedObject { - vis: Vis; - description?: string; - searchSource: SearchSource; - title: string; - uiStateJSON?: string; - destroy: () => void; -} - -export interface VisResponseData { - as: string; - value: VisResponseValue; -} - -/** - * The parameters accepted by the embedVisualize calls. - */ -export interface VisualizeLoaderParams { - /** - * An object with a from/to key, that must be either a date in ISO format, or a - * valid datetime Elasticsearch expression, e.g.: { from: 'now-7d/d', to: 'now' } - */ - timeRange?: TimeRange; - /** - * If set to true, the visualization will be appended to the passed element instead - * of replacing all its content. (default: false) - */ - append?: boolean; - /** - * If specified this CSS class (or classes with space separated) will be set to - * the root visualize element. - */ - cssClass?: string; - /** - * An object of key-value pairs, that will be set as data-{key}="{value}" attributes - * on the visualization element. - */ - dataAttrs?: { [key: string]: string }; - /** - * Specifies the filters that should be applied to that visualization. - */ - filters?: esFilters.Filter[]; - /** - * The query that should apply to that visualization. - */ - query?: Query; - /** - * The current uiState of the application. If you don't pass a uiState, the - * visualization will creates it's own uiState to store information like whether - * the legend is open or closed, but you don't have access to it from the outside. - * Pass one in if you need that access, e.g. for saving that state. - */ - uiState?: PersistedState; - /** - * The appState this visualization should use. If you don't specify it, the - * global AppState (that is decoded in the URL) will be used. Usually you don't - * need to overwrite this, unless you don't want the visualization to use the - * global AppState. - */ - appState?: AppState; - /** - * Whether or not the visualization should fetch its data automatically. If this is - * set to `false` the loader won't trigger a fetch on embedding or when an auto refresh - * cycle happens. Default value: `true` - */ - autoFetch?: boolean; -} - -/** - * The subset of properties allowed to update on an already embedded visualization. - */ -export type VisualizeUpdateParams = Pick< - VisualizeLoaderParams, - 'timeRange' | 'dataAttrs' | 'filters' | 'query' ->; diff --git a/src/legacy/ui/public/visualize/loader/utils/query_geohash_bounds.ts b/src/legacy/ui/public/visualize/loader/utils/query_geohash_bounds.ts index 9f3aa190917d7..912afab74bef4 100644 --- a/src/legacy/ui/public/visualize/loader/utils/query_geohash_bounds.ts +++ b/src/legacy/ui/public/visualize/loader/utils/query_geohash_bounds.ts @@ -25,11 +25,13 @@ import { AggConfig } from 'ui/vis'; import { Query } from 'src/legacy/core_plugins/data/public'; import { timefilter } from 'ui/timefilter'; import { Vis } from '../../../vis'; +import { SearchSource } from '../../../courier'; import { esFilters } from '../../../../../../plugins/data/public'; interface QueryGeohashBoundsParams { filters?: esFilters.Filter[]; query?: Query; + searchSource?: SearchSource; } /** @@ -47,7 +49,9 @@ export async function queryGeohashBounds(vis: Vis, params: QueryGeohashBoundsPar }); if (agg) { - const searchSource = vis.searchSource.createChild(); + const searchSource = params.searchSource + ? params.searchSource.createChild() + : new SearchSource(); searchSource.setField('size', 0); searchSource.setField('aggs', () => { const geoBoundsAgg = vis.getAggConfig().createAggConfig( diff --git a/src/legacy/ui/public/visualize/loader/vis.js b/src/legacy/ui/public/visualize/loader/vis.js index 85ab07528b846..1942fd58afebb 100644 --- a/src/legacy/ui/public/visualize/loader/vis.js +++ b/src/legacy/ui/public/visualize/loader/vis.js @@ -33,8 +33,7 @@ import { PersistedState } from '../../persisted_state'; import { start as visualizations } from '../../../../core_plugins/visualizations/public/np_ready/public/legacy'; - -export function VisProvider(indexPatterns, getAppState) { +export function VisProvider(getAppState) { const visTypes = visualizations.types; class Vis extends EventEmitter { diff --git a/src/legacy/ui/public/visualize/loader/visualization_loader.tsx b/src/legacy/ui/public/visualize/loader/visualization_loader.tsx deleted file mode 100644 index 307ef0354f451..0000000000000 --- a/src/legacy/ui/public/visualize/loader/visualization_loader.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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 _ from 'lodash'; -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; - -import { PersistedState } from '../../persisted_state'; -import { Vis } from '../../vis'; -import { Visualization } from '../components/visualization'; - -interface VisualizationLoaderParams { - listenOnChange?: boolean; -} - -function renderVisualization( - element: HTMLElement, - vis: Vis, - visData: any, - visParams: any, - uiState: PersistedState, - params: VisualizationLoaderParams -) { - return new Promise(resolve => { - const listenOnChange = _.get(params, 'listenOnChange', false); - render( - , - element - ); - }); -} - -function destroy(element?: HTMLElement) { - if (element) { - unmountComponentAtNode(element); - } -} - -export const visualizationLoader = { - render: renderVisualization, - destroy, -}; diff --git a/src/legacy/ui/public/visualize/loader/visualize_loader.ts b/src/legacy/ui/public/visualize/loader/visualize_loader.ts deleted file mode 100644 index 086b16711a581..0000000000000 --- a/src/legacy/ui/public/visualize/loader/visualize_loader.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* - * 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. - */ - -/** - * IMPORTANT: If you make changes to this API, please make sure to check that - * the docs (docs/development/visualize/development-create-visualization.asciidoc) - * are up to date. - */ - -import chrome from '../../chrome'; -import { FilterBarQueryFilterProvider } from '../../filter_manager/query_filter'; -import { IPrivate } from '../../private'; -import { EmbeddedVisualizeHandler } from './embedded_visualize_handler'; -import { VisSavedObject, VisualizeLoaderParams } from './types'; - -export class VisualizeLoader { - constructor(private readonly savedVisualizations: any, private readonly Private: IPrivate) {} - - /** - * Renders a saved visualization specified by its id into a DOM element. - * - * @param element The DOM element to render the visualization into. - * You can alternatively pass a jQuery element instead. - * @param id The id of the saved visualization. This is the id of the - * saved object that is stored in the .kibana index. - * @param params A list of parameters that will influence rendering. - * - * @return A promise that resolves to the - * handler for this visualization as soon as the saved object could be found. - */ - public async embedVisualizationWithId( - element: HTMLElement, - savedVisualizationId: string, - params: VisualizeLoaderParams - ) { - return new Promise((resolve, reject) => { - this.savedVisualizations.get(savedVisualizationId).then((savedObj: VisSavedObject) => { - const handler = this.renderVis(element, savedObj, params); - resolve(handler); - }, reject); - }); - } - - /** - * Renders a saved visualization specified by its savedObject into a DOM element. - * In most of the cases you will need this method, since it allows you to specify - * filters, handlers, queries, etc. on the savedObject before rendering. - * - * We do not encourage you to use this method, since it will most likely be changed - * or removed in a future version of Kibana. Rather embed a visualization by its id - * via the {@link #embedVisualizationWithId} method. - * - * @deprecated You should rather embed by id, since this method will be removed in the future. - * @param element The DOM element to render the visualization into. - * You can alternatively pass a jQuery element instead. - * @param savedObj The savedObject as it could be retrieved by the - * `savedVisualizations` service. - * @param params A list of parameters that will influence rendering. - * - * @return The handler to the visualization. - */ - public embedVisualizationWithSavedObject( - el: HTMLElement, - savedObj: VisSavedObject, - params: VisualizeLoaderParams - ) { - return this.renderVis(el, savedObj, params); - } - - /** - * Returns a promise, that resolves to a list of all saved visualizations. - * - * @return Resolves with a list of all saved visualizations as - * returned by the `savedVisualizations` service in Kibana. - */ - public getVisualizationList(): Promise { - return this.savedVisualizations.find().then((result: any) => result.hits); - } - - private renderVis( - container: HTMLElement, - savedObj: VisSavedObject, - params: VisualizeLoaderParams - ) { - const { vis, description, searchSource } = savedObj; - - vis.description = description; - vis.searchSource = searchSource; - - if (!params.append) { - container.innerHTML = ''; - } - - const element = document.createElement('div'); - element.className = 'visualize'; - element.setAttribute('data-test-subj', 'visualizationLoader'); - container.appendChild(element); - // We need the container to have display: flex so visualization will render correctly - container.style.display = 'flex'; - - // If params specified cssClass, we will set this to the element. - if (params.cssClass) { - params.cssClass.split(' ').forEach(cssClass => { - element.classList.add(cssClass); - }); - } - - // Apply data- attributes to the element if specified - const dataAttrs = params.dataAttrs; - if (dataAttrs) { - Object.keys(dataAttrs).forEach(key => { - element.setAttribute(`data-${key}`, dataAttrs[key]); - }); - } - - const handlerParams = { - ...params, - // lets add query filter angular service to the params - queryFilter: this.Private(FilterBarQueryFilterProvider), - // lets add Private to the params, we'll need to pass it to visualize later - Private: this.Private, - }; - - return new EmbeddedVisualizeHandler(element, savedObj, handlerParams); - } -} - -function VisualizeLoaderProvider(savedVisualizations: any, Private: IPrivate) { - return new VisualizeLoader(savedVisualizations, Private); -} - -/** - * Returns a promise, that resolves with the visualize loader, once it's ready. - * @return A promise, that resolves to the visualize loader. - */ -function getVisualizeLoader(): Promise { - return chrome.dangerouslyGetActiveInjector().then($injector => { - const Private: IPrivate = $injector.get('Private'); - return Private(VisualizeLoaderProvider); - }); -} - -export { getVisualizeLoader, VisualizeLoaderProvider }; diff --git a/src/plugins/expressions/public/expression_types/number.ts b/src/plugins/expressions/public/expression_types/number.ts index 8434536f8f6b8..52b2bb1ff3194 100644 --- a/src/plugins/expressions/public/expression_types/number.ts +++ b/src/plugins/expressions/public/expression_types/number.ts @@ -17,6 +17,7 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { ExpressionType } from '../types'; import { Datatable } from './datatable'; import { Render } from './render'; @@ -28,7 +29,20 @@ export const number = (): ExpressionType => ({ from: { null: () => 0, boolean: b => Number(b), - string: n => Number(n), + string: n => { + const value = Number(n); + if (Number.isNaN(value)) { + throw new Error( + i18n.translate('expressions_np.types.number.fromStringConversionErrorMessage', { + defaultMessage: 'Can\'t typecast "{string}" string to number', + values: { + string: n, + }, + }) + ); + } + return value; + }, }, to: { render: (value: number): Render<{ text: string }> => { diff --git a/src/legacy/core_plugins/kibana/public/table_list_view/index.js b/src/plugins/expressions/public/expression_types/tests/number.test.ts similarity index 72% rename from src/legacy/core_plugins/kibana/public/table_list_view/index.js rename to src/plugins/expressions/public/expression_types/tests/number.test.ts index ae3e5d022c725..3336a1384ea79 100644 --- a/src/legacy/core_plugins/kibana/public/table_list_view/index.js +++ b/src/plugins/expressions/public/expression_types/tests/number.test.ts @@ -17,5 +17,12 @@ * under the License. */ -export { TableListView } from './table_list_view'; +import { number } from '../number'; +describe('number', () => { + it('should fail when typecasting not numeric string to number', () => { + expect(() => number().from!.string('123test', {})).toThrowErrorMatchingInlineSnapshot( + `"Can't typecast \\"123test\\" string to number"` + ); + }); +}); diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index 2d66216a9770b..87ef810682f60 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -91,6 +91,7 @@ export interface IExpressionLoaderParams { customFunctions?: []; customRenderers?: []; extraHandlers?: Record; + inspectorAdapters?: Adapters; } export interface IInterpreterHandlers { diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index cd2ae89b05b5d..cf025ec2e88d4 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -23,3 +23,4 @@ export * from './context'; export * from './overlays'; export * from './ui_settings'; export * from './field_icon'; +export * from './table_list_view'; diff --git a/src/legacy/ui/public/visualize/index.ts b/src/plugins/kibana_react/public/table_list_view/index.ts similarity index 95% rename from src/legacy/ui/public/visualize/index.ts rename to src/plugins/kibana_react/public/table_list_view/index.ts index 46a8968358294..d9a4db50ab7fb 100644 --- a/src/legacy/ui/public/visualize/index.ts +++ b/src/plugins/kibana_react/public/table_list_view/index.ts @@ -16,5 +16,4 @@ * specific language governing permissions and limitations * under the License. */ - -export * from './loader'; +export * from './table_list_view'; diff --git a/src/legacy/core_plugins/kibana/public/table_list_view/table_list_view.js b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx similarity index 66% rename from src/legacy/core_plugins/kibana/public/table_list_view/table_list_view.js rename to src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index 3148a4a37c9c0..7d95c00e76419 100644 --- a/src/legacy/core_plugins/kibana/public/table_list_view/table_list_view.js +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -18,13 +18,12 @@ */ import React from 'react'; -import PropTypes from 'prop-types'; -import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import _ from 'lodash'; -import { toastNotifications } from 'ui/notify'; +import { debounce, indexBy, sortBy, uniq } from 'lodash'; import { EuiTitle, + // @ts-ignore EuiInMemoryTable, EuiPage, EuiPageBody, @@ -38,26 +37,66 @@ import { EuiConfirmModal, EuiCallOut, } from '@elastic/eui'; - -import { npStart } from 'ui/new_platform'; +import { ToastsStart, UiSettingsClientContract } from 'kibana/public'; export const EMPTY_FILTER = ''; +interface Column { + name: string; + width?: string; + actions?: object[]; +} + +interface Item { + id?: string; +} + +export interface TableListViewProps { + createItem?(): void; + deleteItems?(items: object[]): Promise; + editItem?(item: object): void; + entityName: string; + entityNamePlural: string; + findItems(query: string): Promise<{ total: number; hits: object[] }>; + listingLimit: number; + initialFilter: string; + noItemsFragment: JSX.Element; + // update possible column types to something like (FieldDataColumn | ComputedColumn | ActionsColumn)[] when they have been added to EUI + tableColumns: Column[]; + tableListTitle: string; + toastNotifications: ToastsStart; + uiSettings: UiSettingsClientContract; +} + +export interface TableListViewState { + items: object[]; + hasInitialFetchReturned: boolean; + isFetchingItems: boolean; + isDeletingItems: boolean; + showDeleteModal: boolean; + showLimitError: boolean; + filter: string; + selectedIds: string[]; + totalItems: number; +} + // saved object client does not support sorting by title because title is only mapped as analyzed // the legacy implementation got around this by pulling `listingLimit` items and doing client side sorting // and not supporting server-side paging. // This component does not try to tackle these problems (yet) and is just feature matching the legacy component // TODO support server side sorting/paging once title and description are sortable on the server. -class TableListViewUi extends React.Component { +class TableListView extends React.Component { + private pagination = {}; + private _isMounted = false; - constructor(props) { + constructor(props: TableListViewProps) { super(props); - const initialPageSize = npStart.core.uiSettings.get('savedObjects:perPage'); + const initialPageSize = props.uiSettings.get('savedObjects:perPage'); this.pagination = { initialPageIndex: 0, initialPageSize, - pageSizeOptions: _.uniq([10, 20, 50, initialPageSize]).sort(), + pageSizeOptions: uniq([10, 20, 50, initialPageSize]).sort(), }; this.state = { items: [], @@ -67,10 +106,9 @@ class TableListViewUi extends React.Component { isDeletingItems: false, showDeleteModal: false, showLimitError: false, - filter: this.props.initialFilter, + filter: props.initialFilter, selectedIds: [], }; - } componentWillMount() { @@ -86,7 +124,7 @@ class TableListViewUi extends React.Component { this.fetchItems(); } - debouncedFetch = _.debounce(async (filter) => { + debouncedFetch = debounce(async (filter: string) => { const response = await this.props.findItems(filter); if (!this._isMounted) { @@ -100,7 +138,7 @@ class TableListViewUi extends React.Component { this.setState({ hasInitialFetchReturned: true, isFetchingItems: false, - items: (!filter ? _.sortBy(response.hits, 'title') : response.hits), + items: !filter ? sortBy(response.hits, 'title') : response.hits, totalItems: response.total, showLimitError: response.total > this.props.listingLimit, }); @@ -108,26 +146,29 @@ class TableListViewUi extends React.Component { }, 300); fetchItems = () => { - this.setState({ - isFetchingItems: true, - }, this.debouncedFetch.bind(null, this.state.filter)); - } + this.setState( + { + isFetchingItems: true, + }, + this.debouncedFetch.bind(null, this.state.filter) + ); + }; deleteSelectedItems = async () => { - if (this.state.isDeletingItems) { + if (this.state.isDeletingItems || !this.props.deleteItems) { return; } this.setState({ - isDeletingItems: true + isDeletingItems: true, }); try { - const itemsById = _.indexBy(this.state.items, 'id'); + const itemsById = indexBy(this.state.items, 'id'); await this.props.deleteItems(this.state.selectedIds.map(id => itemsById[id])); } catch (error) { - toastNotifications.addDanger({ + this.props.toastNotifications.addDanger({ title: ( @@ -138,25 +179,28 @@ class TableListViewUi extends React.Component { this.fetchItems(); this.setState({ isDeletingItems: false, - selectedIds: [] + selectedIds: [], }); this.closeDeleteModal(); - } + }; closeDeleteModal = () => { this.setState({ showDeleteModal: false }); - } + }; openDeleteModal = () => { this.setState({ showDeleteModal: true }); - } + }; - setFilter(filter) { + setFilter({ queryText }: { queryText: string }) { // If the user is searching, we want to clear the sort order so that // results are ordered by Elasticsearch's relevance. - this.setState({ - filter: filter.queryText, - }, this.fetchItems); + this.setState( + { + filter: queryText, + }, + this.fetchItems + ); } hasNoItems() { @@ -170,14 +214,14 @@ class TableListViewUi extends React.Component { renderConfirmDeleteModal() { let deleteButton = ( ); if (this.state.isDeletingItems) { deleteButton = ( ); @@ -188,11 +232,14 @@ class TableListViewUi extends React.Component { } @@ -201,7 +248,7 @@ class TableListViewUi extends React.Component { onConfirm={this.deleteSelectedItems} cancelButtonText={ } @@ -210,7 +257,7 @@ class TableListViewUi extends React.Component { >

@@ -227,7 +274,7 @@ class TableListViewUi extends React.Component { } @@ -236,26 +283,22 @@ class TableListViewUi extends React.Component { >

- listingLimit - - ), + listingLimitText: listingLimit, advancedSettingsLink: ( - ) + ), }} />

@@ -268,18 +311,15 @@ class TableListViewUi extends React.Component { renderNoItemsMessage() { if (this.props.noItemsFragment) { - return ( - this.props.noItemsFragment - ); + return this.props.noItemsFragment; } else { return ( ); - } } @@ -302,11 +342,12 @@ class TableListViewUi extends React.Component { data-test-subj="deleteSelectedItems" > @@ -314,25 +355,34 @@ class TableListViewUi extends React.Component { } renderTable() { - const selection = this.props.deleteItems ? { - onSelectionChange: (selection) => { - this.setState({ - selectedIds: selection.map(item => { return item.id; }) - }); - } - } : null; - - const actions = [{ - name: i18n.translate('kbn.table_list_view.listing.table.editActionName', { - defaultMessage: 'Edit' - }), - description: i18n.translate('kbn.table_list_view.listing.table.editActionDescription', { - defaultMessage: 'Edit' - }), - icon: 'pencil', - type: 'icon', - onClick: this.props.editItem - }]; + const selection = this.props.deleteItems + ? { + onSelectionChange: (obj: Item[]) => { + this.setState({ + selectedIds: obj + .map(item => item.id) + .filter((id: undefined | string): id is string => Boolean(id)), + }); + }, + } + : null; + + const actions = [ + { + name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'kibana-react.tableListView.listing.table.editActionDescription', + { + defaultMessage: 'Edit', + } + ), + icon: 'pencil', + type: 'icon', + onClick: this.props.editItem, + }, + ]; const search = { onChange: this.setFilter.bind(this), @@ -346,17 +396,17 @@ class TableListViewUi extends React.Component { const columns = this.props.tableColumns.slice(); if (this.props.editItem) { columns.push({ - name: i18n.translate('kbn.table_list_view.listing.table.actionTitle', { - defaultMessage: 'Actions' + name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', { + defaultMessage: 'Actions', }), width: '100px', - actions + actions, }); } const noItemsMessage = ( @@ -397,7 +447,7 @@ class TableListViewUi extends React.Component { fill > @@ -412,14 +462,11 @@ class TableListViewUi extends React.Component { -

- {this.props.tableListTitle} -

+

{this.props.tableListTitle}

{createButton} -
@@ -450,34 +497,10 @@ class TableListViewUi extends React.Component { className="itemListing__page" restrictWidth > - - {this.renderPageContent()} - + {this.renderPageContent()} ); } } -TableListViewUi.propTypes = { - tableColumns: PropTypes.array.isRequired, - - noItemsFragment: PropTypes.object, - - findItems: PropTypes.func.isRequired, - deleteItems: PropTypes.func, - createItem: PropTypes.func, - editItem: PropTypes.func, - - listingLimit: PropTypes.number, - initialFilter: PropTypes.string, - - entityName: PropTypes.string.isRequired, - entityNamePlural: PropTypes.string.isRequired, - tableListTitle: PropTypes.string.isRequired, -}; - -TableListViewUi.defaultProps = { - initialFilter: EMPTY_FILTER, -}; - -export const TableListView = injectI18n(TableListViewUi); +export { TableListView }; diff --git a/test/functional/apps/visualize/_tile_map.js b/test/functional/apps/visualize/_tile_map.js index 7719ecca56a65..0e580f6a7ab3f 100644 --- a/test/functional/apps/visualize/_tile_map.js +++ b/test/functional/apps/visualize/_tile_map.js @@ -221,7 +221,7 @@ export default function ({ getService, getPageObjects }) { it('when not checked does not add filters to aggregation', async () => { await PageObjects.visualize.toggleOpenEditor(2); - await PageObjects.visualize.toggleIsFilteredByCollarCheckbox(); + await PageObjects.visualize.setIsFilteredByCollarCheckbox(false); await PageObjects.visualize.clickGo(); await inspector.open(); await inspector.expectTableHeaders(['geohash_grid', 'Count', 'Geo Centroid']); @@ -229,7 +229,7 @@ export default function ({ getService, getPageObjects }) { }); after(async () => { - await PageObjects.visualize.toggleIsFilteredByCollarCheckbox(); + await PageObjects.visualize.setIsFilteredByCollarCheckbox(true); await PageObjects.visualize.clickGo(); }); }); diff --git a/test/functional/page_objects/visualize_page.js b/test/functional/page_objects/visualize_page.js index 67494f201adae..f3a90f20b6686 100644 --- a/test/functional/page_objects/visualize_page.js +++ b/test/functional/page_objects/visualize_page.js @@ -1007,6 +1007,16 @@ export function VisualizePageProvider({ getService, getPageObjects, updateBaseli await testSubjects.click('isFilteredByCollarCheckbox'); } + async setIsFilteredByCollarCheckbox(value = true) { + await retry.try(async () => { + const isChecked = await this.isChecked('isFilteredByCollarCheckbox'); + if (isChecked !== value) { + await testSubjects.click('isFilteredByCollarCheckbox'); + throw new Error('isFilteredByCollar not set correctly'); + } + }); + } + async getMarkdownData() { const markdown = await retry.try(async () => find.byCssSelector('visualize')); return await markdown.getVisibleText(); diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app.js b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app.js index bd58184cd1185..b0db26c0c6743 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app.js +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app.js @@ -24,9 +24,6 @@ import { uiModules } from 'ui/modules'; import chrome from 'ui/chrome'; import { RequestAdapter, DataAdapter } from 'ui/inspector/adapters'; -import { runPipeline } from 'ui/visualize/loader/pipeline_helpers'; -import { visualizationLoader } from 'ui/visualize/loader/visualization_loader'; - import { registries } from 'plugins/interpreter/registries'; // This is required so some default styles and required scripts/Angular modules are loaded, @@ -58,6 +55,17 @@ app.config(stateManagementConfigProvider => stateManagementConfigProvider.disable() ); +import { fromExpression } from '@kbn/interpreter/common'; +import { getInterpreter } from '../../../../../src/legacy/core_plugins/interpreter/public/interpreter'; + +const runPipeline = async (expression, context, handlers) => { + const ast = fromExpression(expression); + const { interpreter } = await getInterpreter(); + const pipelineResponse = await interpreter.interpretAst(ast, context, handlers); + return pipelineResponse; +}; + + function RootController($scope, $element) { const domNode = $element[0]; @@ -67,7 +75,6 @@ function RootController($scope, $element) { DataAdapter={DataAdapter} runPipeline={runPipeline} registries={registries} - visualizationLoader={visualizationLoader} />, domNode); // unmount react on controller destroy diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/components/main.js b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/components/main.js index 3b1744457c25a..62ba8dd16fef4 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/components/main.js +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/components/main.js @@ -64,7 +64,6 @@ class Main extends React.Component { this.setState({ expression: 'Renderer was not found in registry!\n\n' + JSON.stringify(context) }); return resolve(); } - props.visualizationLoader.destroy(this.chartDiv); const renderCompleteHandler = () => { resolve('render complete'); this.chartDiv.removeEventListener('renderComplete', renderCompleteHandler); diff --git a/test/interpreter_functional/test_suites/run_pipeline/index.js b/test/interpreter_functional/test_suites/run_pipeline/index.js index 3c1ce2314f55f..ebc0568ebb955 100644 --- a/test/interpreter_functional/test_suites/run_pipeline/index.js +++ b/test/interpreter_functional/test_suites/run_pipeline/index.js @@ -25,7 +25,7 @@ export default function ({ getService, getPageObjects, loadTestFile }) { const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['common', 'header']); - describe('runPipeline', function () { + describe.skip('runPipeline', function () { this.tags(['skipFirefox']); before(async () => { diff --git a/test/plugin_functional/config.js b/test/plugin_functional/config.js index e5ad767349358..a6316c607a7c7 100644 --- a/test/plugin_functional/config.js +++ b/test/plugin_functional/config.js @@ -32,7 +32,6 @@ export default async function ({ readConfigFile }) { testFiles: [ require.resolve('./test_suites/app_plugins'), require.resolve('./test_suites/custom_visualizations'), - require.resolve('./test_suites/embedding_visualizations'), require.resolve('./test_suites/panel_actions'), require.resolve('./test_suites/search'), diff --git a/test/plugin_functional/plugins/kbn_tp_visualize_embedding/index.js b/test/plugin_functional/plugins/kbn_tp_visualize_embedding/index.js deleted file mode 100644 index 1ec4ea2b9e096..0000000000000 --- a/test/plugin_functional/plugins/kbn_tp_visualize_embedding/index.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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. - */ - -export default function (kibana) { - return new kibana.Plugin({ - uiExports: { - app: { - title: 'Embedding Vis', - description: 'This is a sample plugin to test embedding of visualizations', - main: 'plugins/kbn_tp_visualize_embedding/app', - } - }, - - init(server) { - // The following lines copy over some configuration variables from Kibana - // to this plugin. This will be needed when embedding visualizations, so that e.g. - // region map is able to get its configuration. - server.injectUiAppVars('kbn_tp_visualize_embedding', async () => { - return await server.getInjectedUiAppVars('kibana'); - }); - } - }); -} diff --git a/test/plugin_functional/plugins/kbn_tp_visualize_embedding/package.json b/test/plugin_functional/plugins/kbn_tp_visualize_embedding/package.json deleted file mode 100644 index f248a7e4d1f2d..0000000000000 --- a/test/plugin_functional/plugins/kbn_tp_visualize_embedding/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "kbn_tp_visualize_embedding", - "version": "1.0.0", - "kibana": { - "version": "kibana", - "templateVersion": "1.0.0" - }, - "license": "Apache-2.0", - "dependencies": { - "@elastic/eui": "14.8.0", - "react": "^16.8.0", - "react-dom": "^16.8.0" - } -} diff --git a/test/plugin_functional/plugins/kbn_tp_visualize_embedding/public/app.js b/test/plugin_functional/plugins/kbn_tp_visualize_embedding/public/app.js deleted file mode 100644 index 4463feac27513..0000000000000 --- a/test/plugin_functional/plugins/kbn_tp_visualize_embedding/public/app.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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 React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; - -import { uiModules } from 'ui/modules'; -import chrome from 'ui/chrome'; - -// This is required so some default styles and required scripts/Angular modules are loaded, -// or the timezone setting is correctly applied. -import 'ui/autoload/all'; - -// These are all the required uiExports you need to import in case you want to embed visualizations. -import 'uiExports/visTypes'; -import 'uiExports/visResponseHandlers'; -import 'uiExports/visRequestHandlers'; -import 'uiExports/visEditorTypes'; -import 'uiExports/visualize'; -import 'uiExports/savedObjectTypes'; -import 'uiExports/fieldFormats'; -import 'uiExports/search'; - -import { Main } from './components/main'; - -const app = uiModules.get('apps/firewallDemoPlugin', ['kibana']); - -app.config($locationProvider => { - $locationProvider.html5Mode({ - enabled: false, - requireBase: false, - rewriteLinks: false, - }); -}); -app.config(stateManagementConfigProvider => - stateManagementConfigProvider.disable() -); - -function RootController($scope, $element) { - const domNode = $element[0]; - - // render react to DOM - render(
, domNode); - - // unmount react on controller destroy - $scope.$on('$destroy', () => { - unmountComponentAtNode(domNode); - }); -} - -chrome.setRootController('firewallDemoPlugin', RootController); diff --git a/test/plugin_functional/plugins/kbn_tp_visualize_embedding/public/components/main.js b/test/plugin_functional/plugins/kbn_tp_visualize_embedding/public/components/main.js deleted file mode 100644 index 677708dfe6e97..0000000000000 --- a/test/plugin_functional/plugins/kbn_tp_visualize_embedding/public/components/main.js +++ /dev/null @@ -1,140 +0,0 @@ -/* - * 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 React from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiLoadingChart, - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPageContentBody, - EuiPageContentHeader, - EuiSelect, -} from '@elastic/eui'; - -import { embeddingSamples } from '../embedding'; - -const VISUALIZATION_OPTIONS = [ - { value: '', text: '' }, - { value: 'timebased', text: 'Time based' }, - { value: 'timebased_with-filters', text: 'Time based (with filters)' }, - { value: 'timebased_no-datehistogram', text: 'Time based data without date histogram' } -]; - -class Main extends React.Component { - - chartDiv = React.createRef(); - state = { - loading: false, - selectedParams: null, - selectedVis: null, - }; - - embedVisualization = async () => { - if (this.handler) { - // Whenever a visualization is about to be removed from DOM that you embedded, - // you need to call `destroy` on the handler to make sure the visualization is - // teared down correctly. - this.handler.destroy(); - this.chartDiv.current.innerHTML = ''; - } - - const { selectedParams, selectedVis } = this.state; - if (selectedParams && selectedVis) { - this.setState({ loading: true }); - const sample = embeddingSamples.find(el => el.id === selectedParams); - this.handler = await sample.run(this.chartDiv.current, selectedVis); - // handler.whenFirstRenderComplete() will return a promise that resolves once the first - // rendering after embedding has finished. - await this.handler.whenFirstRenderComplete(); - this.setState({ loading: false }); - } - } - - onChangeVisualization = async (ev) => { - this.setState({ - selectedVis: ev.target.value, - }, this.embedVisualization); - }; - - onSelectSample = async (ev) => { - this.setState({ - selectedParams: ev.target.value, - }, this.embedVisualization); - }; - - render() { - const samples = [ - { value: '', text: '' }, - ...embeddingSamples.map(({ id, title }) => ({ - value: id, - text: title, - })) - ]; - - return ( - - - - - - - - - - - - - - - - { this.state.loading && - - - - } - - - - {/* - The element you want to render into should have its dimension set (via a fixed height, flexbox, absolute positioning, etc.), - since the visualization will render with exactly the size of that element, i.e. the container size determines the - visualization size. - */} -
- - - - - ); - } -} - -export { Main }; diff --git a/test/plugin_functional/plugins/kbn_tp_visualize_embedding/public/embedding.js b/test/plugin_functional/plugins/kbn_tp_visualize_embedding/public/embedding.js deleted file mode 100644 index 190e6331837b9..0000000000000 --- a/test/plugin_functional/plugins/kbn_tp_visualize_embedding/public/embedding.js +++ /dev/null @@ -1,187 +0,0 @@ -/* - * 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. - */ - -/** - * This files shows a couple of examples how to use the visualize loader API - * to embed visualizations. - */ - -import { getVisualizeLoader } from 'ui/visualize'; -import chrome from 'ui/chrome'; - -export const embeddingSamples = [ - - { - id: 'none', - title: 'No parameters', - async run(domNode, id) { - // You always need to retrieve the visualize loader for embedding visualizations. - const loader = await getVisualizeLoader(); - // Use the embedVisualizationWithId method to embed a visualization by its id. The id is the id of the - // saved object in the .kibana index (you can find the id via Management -> Saved Objects). - // - // Pass in a DOM node that you want to embed that visualization into. Note: the loader will - // use the size of that DOM node. - // - // The call will return a handler for the visualization with methods to interact with it. - // Check the components/main.js file to see how this handler is used. Most important: you need to call - // `destroy` on the handler once you are about to remove the visualization from the DOM. - // - // Note: If the visualization you want to embed contains date histograms with an auto interval, you need - // to specify the timeRange parameter (see below). - return loader.embedVisualizationWithId(domNode, id, {}); - } - }, { - id: 'timerange', - title: 'timeRange', - async run(domNode, id) { - const loader = await getVisualizeLoader(); - // If you want to filter down the data to a specific time range, you can specify a - // timeRange in the parameters to the embedding call. - // You can either use an absolute time range as seen below. You can also specify - // a datemath string, like "now-7d", "now-1w/w" for the from or to key. - // You can also directly assign a moment JS or regular JavaScript Date object. - return loader.embedVisualizationWithId(domNode, id, { - timeRange: { - from: '2015-09-20 20:00:00.000', - to: '2015-09-21 20:00:00.000', - } - }); - } - }, { - id: 'query', - title: 'query', - async run(domNode, id) { - const loader = await getVisualizeLoader(); - // You can specify a query that should filter down the data via the query parameter. - // It must have a language key which must be one of the supported query languages of Kibana, - // which are at the moment: 'lucene' or 'kquery'. - // The query key must then hold the actual query in the specified language for filtering. - return loader.embedVisualizationWithId(domNode, id, { - query: { - language: 'lucene', - query: 'extension.raw:jpg', - } - }); - } - }, { - id: 'filters', - title: 'filters', - async run(domNode, id) { - const loader = await getVisualizeLoader(); - // You can specify an array of filters that should apply to the query. - // The format of a filter must match the format the filter bar is using internally. - // This has a query key, which holds the query part of an Elasticsearch query - // and a meta key allowing to set some meta values, most important for this API - // the `negate` option to negate the filter. - return loader.embedVisualizationWithId(domNode, id, { - filters: [ - { - query: { - bool: { - should: [ - { match_phrase: { 'extension.raw': 'jpg' } }, - { match_phrase: { 'extension.raw': 'png' } }, - ] - } - }, - meta: { - negate: true - } - } - ] - }); - } - }, { - id: 'filters_query_timerange', - title: 'filters & query & timeRange', - async run(domNode, id) { - const loader = await getVisualizeLoader(); - // You an of course combine timeRange, query and filters options all together - // to filter the data in the embedded visualization. - return loader.embedVisualizationWithId(domNode, id, { - timeRange: { - from: '2015-09-20 20:00:00.000', - to: '2015-09-21 20:00:00.000', - }, - query: { - language: 'lucene', - query: 'bytes:>2000' - }, - filters: [ - { - query: { - bool: { - should: [ - { match_phrase: { 'extension.raw': 'jpg' } }, - { match_phrase: { 'extension.raw': 'png' } }, - ] - } - }, - meta: { - negate: true - } - } - ] - }); - } - }, { - id: 'savedobject_filter_query_timerange', - title: 'filters & query & time (use saved object)', - async run(domNode, id) { - const loader = await getVisualizeLoader(); - // Besides embedding via the id of the visualizataion, the API offers the possibility to - // embed via the saved visualization object. - // - // WE ADVISE YOU NOT TO USE THIS INSIDE ANY PLUGIN! - // - // Since the format of the saved visualization object will change in the future and because - // this still requires you to talk to old Angular code, we do not encourage you to use this - // way of embedding in any plugin. It's likely it will be removed or changed in a future version. - const $injector = await chrome.dangerouslyGetActiveInjector(); - const savedVisualizations = $injector.get('savedVisualizations'); - const savedVis = await savedVisualizations.get(id); - return loader.embedVisualizationWithSavedObject(domNode, savedVis, { - timeRange: { - from: '2015-09-20 20:00:00.000', - to: '2015-09-21 20:00:00.000', - }, - query: { - language: 'lucene', - query: 'bytes:>2000' - }, - filters: [ - { - query: { - bool: { - should: [ - { match_phrase: { 'extension.raw': 'jpg' } }, - { match_phrase: { 'extension.raw': 'png' } }, - ] - } - }, - meta: { - negate: true - } - } - ] - }); - } - } -]; diff --git a/test/plugin_functional/test_suites/embedding_visualizations/embed_by_id.js b/test/plugin_functional/test_suites/embedding_visualizations/embed_by_id.js deleted file mode 100644 index c877ec2e5e025..0000000000000 --- a/test/plugin_functional/test_suites/embedding_visualizations/embed_by_id.js +++ /dev/null @@ -1,189 +0,0 @@ -/* - * 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 expect from '@kbn/expect'; -import { delay } from 'bluebird'; - -export default function ({ getService }) { - const testSubjects = getService('testSubjects'); - const find = getService('find'); - const table = getService('table'); - const retry = getService('retry'); - - async function selectVis(id) { - await testSubjects.click('visSelect'); - await find.clickByCssSelector(`option[value="${id}"]`); - } - - async function selectParams(id) { - await testSubjects.click('embeddingParamsSelect'); - await find.clickByCssSelector(`option[value="${id}"]`); - await retry.try(async () => { - await testSubjects.waitForDeleted('visLoadingIndicator'); - }); - await delay(1000); - } - - async function getTableData() { - const data = await table.getDataFromTestSubj('paginated-table-body'); - // Strip away empty rows (at the bottom) - return data.filter(row => !row.every(cell => !cell.trim())); - } - - describe('embed by id', function describeIndexTests() { - describe('vis on timebased data without date histogram', () => { - before(async () => { - await selectVis('timebased_no-datehistogram'); - }); - - it('should correctly embed', async () => { - await selectParams('none'); - const data = await getTableData(); - expect(data).to.be.eql([ - ['jpg', '9,109'], - ['css', '2,159'], - ['png', '1,373'], - ['gif', '918'], - ['php', '445'], - ]); - }); - - it('should correctly embed specifying a timeRange', async () => { - await selectParams('timerange'); - const data = await getTableData(); - expect(data).to.be.eql([ - ['jpg', '3,005'], - ['css', '720'], - ['png', '455'], - ['gif', '300'], - ['php', '142'], - ]); - }); - - it('should correctly embed specifying a query', async () => { - await selectParams('query'); - const data = await getTableData(); - expect(data).to.be.eql([ - ['jpg', '9,109'], - ]); - }); - - it('should correctly embed specifying filters', async () => { - await selectParams('filters'); - const data = await getTableData(); - expect(data).to.be.eql([ - ['css', '2,159'], - ['gif', '918'], - ['php', '445'], - ]); - }); - - it('should correctly embed specifying filters and query and timeRange', async () => { - await selectParams('filters_query_timerange'); - const data = await getTableData(); - expect(data).to.be.eql([ - ['css', '678'], - ['php', '110'], - ]); - }); - }); - - describe('vis on timebased data with date histogram with interval auto', () => { - before(async () => { - await selectVis('timebased'); - }); - - it('should correctly embed specifying a timeRange', async () => { - await selectParams('timerange'); - const data = await getTableData(); - expect(data).to.be.eql([ - ['2015-09-20 20:00', '45.159KB', '5.65KB'], - ['2015-09-21 00:00', '42.428KB', '5.345KB'], - ['2015-09-21 04:00', '43.717KB', '5.35KB'], - ['2015-09-21 08:00', '43.228KB', '5.538KB'], - ['2015-09-21 12:00', '42.83KB', '5.669KB'], - ['2015-09-21 16:00', '44.908KB', '5.673KB'], - ]); - }); - - it('should correctly embed specifying filters and query and timeRange', async () => { - await selectParams('filters_query_timerange'); - const data = await getTableData(); - expect(data).to.be.eql([ - ['2015-09-20 20:00', '45.391KB', '5.692KB'], - ['2015-09-21 00:00', '46.57KB', '5.953KB'], - ['2015-09-21 04:00', '47.339KB', '6.636KB'], - ['2015-09-21 08:00', '40.5KB', '6.133KB'], - ['2015-09-21 12:00', '41.31KB', '5.84KB'], - ['2015-09-21 16:00', '48.012KB', '6.003KB'], - ]); - }); - }); - - describe('vis on timebased data with date histogram with interval auto and saved filters', () => { - before(async () => { - await selectVis('timebased_with-filters'); - }); - - it('should correctly embed specifying a timeRange', async () => { - await selectParams('timerange'); - const data = await getTableData(); - expect(data).to.be.eql([ - ['2015-09-20 20:00', '21.221KB', '2.66KB'], - ['2015-09-21 00:00', '22.054KB', '2.63KB'], - ['2015-09-21 04:00', '15.592KB', '2.547KB'], - ['2015-09-21 08:00', '4.656KB', '2.328KB'], - ['2015-09-21 12:00', '17.887KB', '2.694KB'], - ['2015-09-21 16:00', '20.533KB', '2.529KB'], - ]); - }); - - it('should correctly embed specifying filters and query and timeRange', async () => { - await selectParams('filters_query_timerange'); - const data = await getTableData(); - expect(data).to.be.eql([ - ['2015-09-20 20:00', '24.567KB', '3.498KB'], - ['2015-09-21 00:00', '25.984KB', '3.589KB'], - ['2015-09-21 04:00', '2.543KB', '2.543KB'], - ['2015-09-21 12:00', '5.783KB', '2.927KB'], - ['2015-09-21 16:00', '21.107KB', '3.44KB'], - ]); - }); - }); - - describe('vis visa saved object on timebased data with date histogram with interval auto and saved filters', () => { - before(async () => { - await selectVis('timebased_with-filters'); - }); - - it('should correctly embed specifying filters and query and timeRange', async () => { - await selectParams('savedobject_filter_query_timerange'); - const data = await getTableData(); - expect(data).to.be.eql([ - ['2015-09-20 20:00', '24.567KB', '3.498KB'], - ['2015-09-21 00:00', '25.984KB', '3.589KB'], - ['2015-09-21 04:00', '2.543KB', '2.543KB'], - ['2015-09-21 12:00', '5.783KB', '2.927KB'], - ['2015-09-21 16:00', '21.107KB', '3.44KB'], - ]); - }); - }); - }); - -} diff --git a/test/plugin_functional/test_suites/embedding_visualizations/index.js b/test/plugin_functional/test_suites/embedding_visualizations/index.js deleted file mode 100644 index b54a500fcd1f2..0000000000000 --- a/test/plugin_functional/test_suites/embedding_visualizations/index.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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. - */ - -export default function ({ getService, getPageObjects, loadTestFile }) { - const browser = getService('browser'); - const appsMenu = getService('appsMenu'); - const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['common', 'header']); - - describe('embedding visualizations', function () { - before(async () => { - await esArchiver.loadIfNeeded('../functional/fixtures/es_archiver/logstash_functional'); - await esArchiver.load('../functional/fixtures/es_archiver/visualize_embedding'); - await kibanaServer.uiSettings.replace({ - 'dateFormat:tz': 'Australia/North', - 'defaultIndex': 'logstash-*', - 'format:bytes:defaultPattern': '0,0.[000]b' - }); - await browser.setWindowSize(1300, 900); - await PageObjects.common.navigateToApp('settings'); - await appsMenu.clickLink('Embedding Vis'); - }); - - loadTestFile(require.resolve('./embed_by_id')); - }); -} diff --git a/x-pack/legacy/plugins/actions/server/shim.ts b/x-pack/legacy/plugins/actions/server/shim.ts index 0da6b84f2cc69..1af62d276f10b 100644 --- a/x-pack/legacy/plugins/actions/server/shim.ts +++ b/x-pack/legacy/plugins/actions/server/shim.ts @@ -42,7 +42,7 @@ export interface KibanaConfig { */ export type TaskManagerStartContract = Pick; export type XPackMainPluginSetupContract = Pick; -export type SecurityPluginSetupContract = Pick; +export type SecurityPluginSetupContract = Pick; export type SecurityPluginStartContract = Pick; export type TaskManagerSetupContract = Pick< TaskManager, diff --git a/x-pack/legacy/plugins/alerting/server/shim.ts b/x-pack/legacy/plugins/alerting/server/shim.ts index d86eab2038095..0ee1ef843d7d0 100644 --- a/x-pack/legacy/plugins/alerting/server/shim.ts +++ b/x-pack/legacy/plugins/alerting/server/shim.ts @@ -41,7 +41,7 @@ export interface Server extends Legacy.Server { * Shim what we're thinking setup and start contracts will look like */ export type TaskManagerStartContract = Pick; -export type SecurityPluginSetupContract = Pick; +export type SecurityPluginSetupContract = Pick; export type SecurityPluginStartContract = Pick; export type XPackMainPluginSetupContract = Pick; export type TaskManagerSetupContract = Pick< diff --git a/x-pack/legacy/plugins/beats_management/public/components/navigation/breadcrumb/breadcrumb.tsx b/x-pack/legacy/plugins/beats_management/public/components/navigation/breadcrumb/breadcrumb.tsx index 6e74fac3af49b..7948501f5f873 100644 --- a/x-pack/legacy/plugins/beats_management/public/components/navigation/breadcrumb/breadcrumb.tsx +++ b/x-pack/legacy/plugins/beats_management/public/components/navigation/breadcrumb/breadcrumb.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { Component } from 'react'; -import { RouteProps } from 'react-router'; +import { RouteProps } from 'react-router-dom'; import { BASE_PATH } from '../../../../common/constants'; import { BreadcrumbConsumer } from './consumer'; import { Breadcrumb as BreadcrumbData, BreadcrumbContext } from './types'; diff --git a/x-pack/legacy/plugins/beats_management/public/frontend_types.d.ts b/x-pack/legacy/plugins/beats_management/public/frontend_types.d.ts index bcaac2b3781aa..21996d10be231 100644 --- a/x-pack/legacy/plugins/beats_management/public/frontend_types.d.ts +++ b/x-pack/legacy/plugins/beats_management/public/frontend_types.d.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { RouteComponentProps } from 'react-router'; +import { RouteComponentProps } from 'react-router-dom'; import { BeatsContainer } from './containers/beats'; import { TagsContainer } from './containers/tags'; import { URLStateProps } from './containers/with_url_state'; diff --git a/x-pack/legacy/plugins/graph/public/components/listing.tsx b/x-pack/legacy/plugins/graph/public/components/listing.tsx index 99df013548840..047590d8d562d 100644 --- a/x-pack/legacy/plugins/graph/public/components/listing.tsx +++ b/x-pack/legacy/plugins/graph/public/components/listing.tsx @@ -10,15 +10,14 @@ import React, { Fragment } from 'react'; import { EuiEmptyPrompt, EuiLink, EuiButton } from '@elastic/eui'; import { CoreStart, ApplicationStart } from 'kibana/public'; -// @ts-ignore -import { TableListView } from '../../../../../../src/legacy/core_plugins/kibana/public/table_list_view/table_list_view'; +import { TableListView } from '../../../../../../src/plugins/kibana_react/public'; import { GraphWorkspaceSavedObject } from '../types'; export interface ListingProps { coreStart: CoreStart; createItem: () => void; - findItems: (query: string, limit: number) => Promise; - deleteItems: (ids: string[]) => Promise; + findItems: (query: string) => Promise<{ total: number; hits: GraphWorkspaceSavedObject[] }>; + deleteItems: (records: GraphWorkspaceSavedObject[]) => Promise; editItem: (record: GraphWorkspaceSavedObject) => void; getViewUrl: (record: GraphWorkspaceSavedObject) => string; listingLimit: number; @@ -31,10 +30,10 @@ export function Listing(props: ListingProps) { return ( ); diff --git a/x-pack/legacy/plugins/infra/public/containers/with_state_from_location.tsx b/x-pack/legacy/plugins/infra/public/containers/with_state_from_location.tsx index 4ed70eda5e569..ec6345c49c303 100644 --- a/x-pack/legacy/plugins/infra/public/containers/with_state_from_location.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/with_state_from_location.tsx @@ -8,7 +8,7 @@ import { Location } from 'history'; import omit from 'lodash/fp/omit'; import { parse as parseQueryString, stringify as stringifyQueryString } from 'querystring'; import React from 'react'; -import { RouteComponentProps, withRouter } from 'react-router'; +import { RouteComponentProps, withRouter } from 'react-router-dom'; // eslint-disable-next-line @typescript-eslint/camelcase import { decode_object, encode_object } from 'rison-node'; import { Omit } from '../lib/lib'; diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.js b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.js index bd8dacb53f534..0d318c41a7fd1 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.js +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.js @@ -32,7 +32,7 @@ function getUniqueLayerCounts(layerCountsList, mapsCount) { }, {}); } -export function buildMapsTelemetry(savedObjects) { +export function buildMapsTelemetry(savedObjects, settings) { const layerLists = savedObjects .map(savedMapObject => JSON.parse(savedMapObject.attributes.layerListJSON)); @@ -57,7 +57,8 @@ export function buildMapsTelemetry(savedObjects) { const dataSourcesCountSum = _.sum(dataSourcesCount); const layersCountSum = _.sum(layersCount); - const mapsTelem = { + return { + settings, // Total count of maps mapsTotalCount: mapsCount, // Time of capture @@ -85,7 +86,6 @@ export function buildMapsTelemetry(savedObjects) { } } }; - return mapsTelem; } async function getSavedObjects(savedObjectsClient) { @@ -98,7 +98,10 @@ async function getSavedObjects(savedObjectsClient) { export async function getMapsTelemetry(server, callCluster) { const savedObjectsClient = getSavedObjectsClient(server, callCluster); const savedObjects = await getSavedObjects(savedObjectsClient); - const mapsTelemetry = buildMapsTelemetry(savedObjects); + const settings = { + showMapVisualizationTypes: server.config().get('xpack.maps.showMapVisualizationTypes') + }; + const mapsTelemetry = buildMapsTelemetry(savedObjects, settings); return await savedObjectsClient.create('maps-telemetry', mapsTelemetry, { diff --git a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js index d2f7b47577005..4f2b983a54028 100644 --- a/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js +++ b/x-pack/legacy/plugins/maps/server/maps_telemetry/maps_telemetry.test.js @@ -10,79 +10,87 @@ import { buildMapsTelemetry } from './maps_telemetry'; describe('buildMapsTelemetry', () => { + const settings = { showMapVisualizationTypes: false }; + test('returns zeroed telemetry data when there are no saved objects', async () => { const gisMaps = []; - const result = buildMapsTelemetry(gisMaps); + const result = buildMapsTelemetry(gisMaps, settings); expect(result).toMatchObject({ - 'attributesPerMap': { - 'dataSourcesCount': { - 'avg': 0, - 'max': 0, - 'min': 0 + attributesPerMap: { + dataSourcesCount: { + avg: 0, + max: 0, + min: 0 }, - 'emsVectorLayersCount': {}, - 'layerTypesCount': {}, - 'layersCount': { - 'avg': 0, - 'max': 0, - 'min': 0 + emsVectorLayersCount: {}, + layerTypesCount: {}, + layersCount: { + avg: 0, + max: 0, + min: 0 } }, - 'mapsTotalCount': 0 + mapsTotalCount: 0, + settings: { + showMapVisualizationTypes: false + } }); }); test('returns expected telemetry data from saved objects', async () => { const gisMaps = savedObjectsPayload.saved_objects; - const result = buildMapsTelemetry(gisMaps); + const result = buildMapsTelemetry(gisMaps, settings); expect(result).toMatchObject({ - 'attributesPerMap': { - 'dataSourcesCount': { - 'avg': 2.6666666666666665, - 'max': 3, - 'min': 2 + attributesPerMap: { + dataSourcesCount: { + avg: 2.6666666666666665, + max: 3, + min: 2 }, - 'emsVectorLayersCount': { - 'canada_provinces': { - 'avg': 0.3333333333333333, - 'max': 1, - 'min': 1 + emsVectorLayersCount: { + canada_provinces: { + avg: 0.3333333333333333, + max: 1, + min: 1 }, - 'france_departments': { - 'avg': 0.3333333333333333, - 'max': 1, - 'min': 1 + france_departments: { + avg: 0.3333333333333333, + max: 1, + min: 1 }, - 'italy_provinces': { - 'avg': 0.3333333333333333, - 'max': 1, - 'min': 1 + italy_provinces: { + avg: 0.3333333333333333, + max: 1, + min: 1 } }, - 'layerTypesCount': { - 'TILE': { - 'avg': 1, - 'max': 1, - 'min': 1 + layerTypesCount: { + TILE: { + avg: 1, + max: 1, + min: 1 }, - 'VECTOR': { - 'avg': 1.6666666666666667, - 'max': 2, - 'min': 1 + VECTOR: { + avg: 1.6666666666666667, + max: 2, + min: 1 } }, - 'layersCount': { - 'avg': 2.6666666666666665, - 'max': 3, - 'min': 2 + layersCount: { + avg: 2.6666666666666665, + max: 3, + min: 2 } }, - 'mapsTotalCount': 3 + mapsTotalCount: 3, + settings: { + showMapVisualizationTypes: false + } }); }); }); diff --git a/x-pack/legacy/plugins/ml/public/components/stats_bar/stat.tsx b/x-pack/legacy/plugins/ml/public/components/stats_bar/stat.tsx index 9de287d54a720..45000a2252ce6 100644 --- a/x-pack/legacy/plugins/ml/public/components/stats_bar/stat.tsx +++ b/x-pack/legacy/plugins/ml/public/components/stats_bar/stat.tsx @@ -8,7 +8,7 @@ import React, { FC } from 'react'; export interface StatsBarStat { label: string; - value: string | number; + value: number; show?: boolean; } interface StatProps { diff --git a/x-pack/legacy/plugins/ml/public/components/stats_bar/stats_bar.tsx b/x-pack/legacy/plugins/ml/public/components/stats_bar/stats_bar.tsx index df87fb0b05c37..4ad1139bc9b52 100644 --- a/x-pack/legacy/plugins/ml/public/components/stats_bar/stats_bar.tsx +++ b/x-pack/legacy/plugins/ml/public/components/stats_bar/stats_bar.tsx @@ -23,7 +23,7 @@ export interface AnalyticStatsBarStats extends Stats { stopped: StatsBarStat; } -type StatsBarStats = JobStatsBarStats | AnalyticStatsBarStats; +export type StatsBarStats = JobStatsBarStats | AnalyticStatsBarStats; type StatsKey = keyof StatsBarStats; interface StatsBarProps { diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 1f66ea40b565a..8e044327610df 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -8,7 +8,14 @@ import React, { Fragment, FC, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonEmpty, EuiCallOut, EuiEmptyPrompt } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiCallOut, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; import { DataFrameAnalyticsId, useRefreshAnalyticsList } from '../../../../common'; import { checkPermission } from '../../../../../privilege/check_privilege'; @@ -22,7 +29,6 @@ import { Query, Clause, } from './common'; -import { ActionDispatchers } from '../../hooks/use_create_analytics_form/actions'; import { getAnalyticsFactory } from '../../services/analytics_service'; import { getColumns } from './columns'; import { ExpandedRow } from './expanded_row'; @@ -33,6 +39,10 @@ import { SortDirection, SORT_DIRECTION, } from '../../../../../components/ml_in_memory_table'; +import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; +import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button'; +import { CreateAnalyticsButton } from '../create_analytics_button'; +import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; function getItemIdToExpandedRowMap( itemIds: DataFrameAnalyticsId[], @@ -62,20 +72,22 @@ interface Props { isManagementTable?: boolean; isMlEnabledInSpace?: boolean; blockRefresh?: boolean; - openCreateJobModal?: ActionDispatchers['openModal']; + createAnalyticsForm?: CreateAnalyticsFormProps; } -// isManagementTable - for use in Kibana managagement ML section export const DataFrameAnalyticsList: FC = ({ isManagementTable = false, isMlEnabledInSpace = true, blockRefresh = false, - openCreateJobModal, + createAnalyticsForm, }) => { const [isInitialized, setIsInitialized] = useState(false); const [isLoading, setIsLoading] = useState(false); const [filterActive, setFilterActive] = useState(false); const [analytics, setAnalytics] = useState([]); + const [analyticsStats, setAnalyticsStats] = useState( + undefined + ); const [filteredAnalytics, setFilteredAnalytics] = useState([]); const [expandedRowItemIds, setExpandedRowItemIds] = useState([]); @@ -94,10 +106,12 @@ export const DataFrameAnalyticsList: FC = ({ const getAnalytics = getAnalyticsFactory( setAnalytics, + setAnalyticsStats, setErrorMessage, setIsInitialized, blockRefresh ); + // Subscribe to the refresh observable to trigger reloading the analytics list. useRefreshAnalyticsList({ isLoading: setIsLoading, @@ -213,9 +227,12 @@ export const DataFrameAnalyticsList: FC = ({ } actions={ - !isManagementTable && openCreateJobModal !== undefined + !isManagementTable && createAnalyticsForm ? [ - + {i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptButtonText', { defaultMessage: 'Create your first data frame analytics job', })} @@ -310,7 +327,28 @@ export const DataFrameAnalyticsList: FC = ({ return ( - + + + {analyticsStats && ( + + + + )} + + + + + + + {!isManagementTable && createAnalyticsForm && ( + + + + )} + + + + { +export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { const kibanaContext = useKibanaContext(); const [state, dispatch] = useReducer(reducer, getInitialState()); const { refresh } = useRefreshAnalyticsList(); diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/page.tsx b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/page.tsx index fcff4aa06b6bb..9d5502569687c 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/page.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/page.tsx @@ -4,29 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, FC, useState } from 'react'; +import React, { FC, Fragment, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiBetaBadge, - EuiFlexGroup, - EuiFlexItem, EuiPage, EuiPageBody, - EuiPageContentBody, - EuiPageContentHeader, - EuiPageContentHeaderSection, - EuiPanel, - EuiSpacer, EuiTitle, + EuiPageHeader, + EuiPageHeaderSection, } from '@elastic/eui'; import { NavigationMenu } from '../../../components/navigation_menu'; -import { CreateAnalyticsButton } from './components/create_analytics_button'; import { DataFrameAnalyticsList } from './components/analytics_list'; -import { RefreshAnalyticsListButton } from './components/refresh_analytics_list_button'; import { useRefreshInterval } from './components/analytics_list/use_refresh_interval'; import { useCreateAnalyticsForm } from './hooks/use_create_analytics_form'; @@ -42,8 +35,8 @@ export const Page: FC = () => { - - + +

{ />

-
- - - {/* grow={false} fixes IE11 issue with nested flex */} - - - - {/* grow={false} fixes IE11 issue with nested flex */} - - - - - -
- - - - - - + + +
diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts new file mode 100644 index 0000000000000..33a073d7a686e --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { GetDataFrameAnalyticsStatsResponseOk } from '../../../../../services/ml_api_service'; +import { getAnalyticsJobsStats } from './get_analytics'; +import { DATA_FRAME_TASK_STATE } from '../../components/analytics_list/common'; + +jest.mock('ui/index_patterns', () => ({ + validateIndexPattern: () => true, +})); + +describe('get_analytics', () => { + test('should get analytics jobs stats', () => { + // arrange + const mockResponse: GetDataFrameAnalyticsStatsResponseOk = { + count: 2, + data_frame_analytics: [ + { + id: 'outlier-cloudwatch', + state: DATA_FRAME_TASK_STATE.STOPPED, + progress: [ + { + phase: 'reindexing', + progress_percent: 0, + }, + { + phase: 'loading_data', + progress_percent: 0, + }, + { + phase: 'analyzing', + progress_percent: 0, + }, + { + phase: 'writing_results', + progress_percent: 0, + }, + ], + }, + { + id: 'reg-gallery', + state: DATA_FRAME_TASK_STATE.FAILED, + progress: [ + { + phase: 'reindexing', + progress_percent: 0, + }, + { + phase: 'loading_data', + progress_percent: 0, + }, + { + phase: 'analyzing', + progress_percent: 0, + }, + { + phase: 'writing_results', + progress_percent: 0, + }, + ], + }, + ], + }; + + // act and assert + expect(getAnalyticsJobsStats(mockResponse)).toEqual({ + total: { + label: 'Total analytics jobs', + value: 2, + show: true, + }, + started: { + label: 'Running', + value: 0, + show: true, + }, + stopped: { + label: 'Stopped', + value: 1, + show: true, + }, + failed: { + label: 'Failed', + value: 1, + show: true, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts index 36fd283cbea70..1875216408c62 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts @@ -4,32 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ml } from '../../../../../services/ml_api_service'; +import { i18n } from '@kbn/i18n'; +import { + GetDataFrameAnalyticsStatsResponse, + GetDataFrameAnalyticsStatsResponseError, + GetDataFrameAnalyticsStatsResponseOk, + ml, +} from '../../../../../services/ml_api_service'; import { DataFrameAnalyticsConfig, - refreshAnalyticsList$, REFRESH_ANALYTICS_LIST_STATE, + refreshAnalyticsList$, } from '../../../../common'; import { - DataFrameAnalyticsListRow, - DataFrameAnalyticsStats, DATA_FRAME_MODE, + DataFrameAnalyticsListRow, + isDataFrameAnalyticsFailed, + isDataFrameAnalyticsRunning, isDataFrameAnalyticsStats, + isDataFrameAnalyticsStopped, } from '../../components/analytics_list/common'; +import { AnalyticStatsBarStats } from '../../../../../components/stats_bar'; interface GetDataFrameAnalyticsResponse { count: number; data_frame_analytics: DataFrameAnalyticsConfig[]; } -interface GetDataFrameAnalyticsStatsResponseOk { - node_failures?: object; - count: number; - data_frame_analytics: DataFrameAnalyticsStats[]; -} - -const isGetDataFrameAnalyticsStatsResponseOk = ( +export const isGetDataFrameAnalyticsStatsResponseOk = ( arg: any ): arg is GetDataFrameAnalyticsStatsResponseOk => { return ( @@ -39,20 +42,71 @@ const isGetDataFrameAnalyticsStatsResponseOk = ( ); }; -interface GetDataFrameAnalyticsStatsResponseError { - statusCode: number; - error: string; - message: string; -} +export type GetAnalytics = (forceRefresh?: boolean) => void; -type GetDataFrameAnalyticsStatsResponse = - | GetDataFrameAnalyticsStatsResponseOk - | GetDataFrameAnalyticsStatsResponseError; +/** + * Gets initial object for analytics stats. + */ +export function getInitialAnalyticsStats(): AnalyticStatsBarStats { + return { + total: { + label: i18n.translate('xpack.ml.overview.statsBar.totalAnalyticsLabel', { + defaultMessage: 'Total analytics jobs', + }), + value: 0, + show: true, + }, + started: { + label: i18n.translate('xpack.ml.overview.statsBar.runningAnalyticsLabel', { + defaultMessage: 'Running', + }), + value: 0, + show: true, + }, + stopped: { + label: i18n.translate('xpack.ml.overview.statsBar.stoppedAnalyticsLabel', { + defaultMessage: 'Stopped', + }), + value: 0, + show: true, + }, + failed: { + label: i18n.translate('xpack.ml.overview.statsBar.failedAnalyticsLabel', { + defaultMessage: 'Failed', + }), + value: 0, + show: false, + }, + }; +} -export type GetAnalytics = (forceRefresh?: boolean) => void; +/** + * Gets analytics jobs stats formatted for the stats bar. + */ +export function getAnalyticsJobsStats( + analyticsStats: GetDataFrameAnalyticsStatsResponseOk +): AnalyticStatsBarStats { + const resultStats: AnalyticStatsBarStats = analyticsStats.data_frame_analytics.reduce( + (acc, { state }) => { + if (isDataFrameAnalyticsFailed(state)) { + acc.failed.value = ++acc.failed.value; + } else if (isDataFrameAnalyticsRunning(state)) { + acc.started.value = ++acc.started.value; + } else if (isDataFrameAnalyticsStopped(state)) { + acc.stopped.value = ++acc.stopped.value; + } + return acc; + }, + getInitialAnalyticsStats() + ); + resultStats.failed.show = resultStats.failed.value > 0; + resultStats.total.value = analyticsStats.count; + return resultStats; +} export const getAnalyticsFactory = ( setAnalytics: React.Dispatch>, + setAnalyticsStats: React.Dispatch>, setErrorMessage: React.Dispatch< React.SetStateAction >, @@ -74,6 +128,10 @@ export const getAnalyticsFactory = ( const analyticsConfigs: GetDataFrameAnalyticsResponse = await ml.dataFrameAnalytics.getDataFrameAnalytics(); const analyticsStats: GetDataFrameAnalyticsStatsResponse = await ml.dataFrameAnalytics.getDataFrameAnalyticsStats(); + const analyticsStatsResult = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats) + ? getAnalyticsJobsStats(analyticsStats) + : undefined; + const tableRows = analyticsConfigs.data_frame_analytics.reduce( (reducedtableRows, config) => { const stats = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats) @@ -100,6 +158,7 @@ export const getAnalyticsFactory = ( ); setAnalytics(tableRows); + setAnalyticsStats(analyticsStatsResult); setErrorMessage(undefined); setIsInitialized(true); refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.IDLE); @@ -109,6 +168,7 @@ export const getAnalyticsFactory = ( refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.ERROR); refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.IDLE); setAnalytics([]); + setAnalyticsStats(undefined); setErrorMessage(e); setIsInitialized(true); } diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/_jobs_list.scss b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/_jobs_list.scss index d94bb5d678279..824f764de3902 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/_jobs_list.scss +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/_jobs_list.scss @@ -1,7 +1,3 @@ -.job-management { - padding: $euiSizeL; -} - .new-job-button-container { float: right; -} \ No newline at end of file +} diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/_jobs_list_view.scss b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/_jobs_list_view.scss index a7d562a9494cd..ef0fbc358193e 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/_jobs_list_view.scss +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/_jobs_list_view.scss @@ -8,11 +8,3 @@ .job-management { padding: 20px; } - -.job-buttons-container { - float: right; -} - -.clear { - clear: both; -} diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 786321c7be6c1..2b60eed5fd248 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ - +import React, { Component } from 'react'; import { timefilter } from 'ui/timefilter'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; import { ml } from 'plugins/ml/services/ml_api_service'; -import { loadFullJob, filterJobs, checkForAutoStartDatafeed } from '../utils'; +import { checkForAutoStartDatafeed, filterJobs, loadFullJob } from '../utils'; import { JobsList } from '../jobs_list'; import { JobDetails } from '../job_details'; import { JobFilterBar } from '../job_filter_bar'; @@ -26,22 +28,11 @@ import { isEqual } from 'lodash'; import { DEFAULT_REFRESH_INTERVAL_MS, - MINIMUM_REFRESH_INTERVAL_MS, DELETING_JOBS_REFRESH_INTERVAL_MS, + MINIMUM_REFRESH_INTERVAL_MS, } from '../../../../../common/constants/jobs_list'; -import React, { - Component -} from 'react'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, -} from '@elastic/eui'; - - -let jobsRefreshInterval = null; +let jobsRefreshInterval = null; let deletingJobsRefreshTimeout = null; // 'isManagementTable' bool prop to determine when to configure table for use in Kibana management page @@ -76,8 +67,8 @@ export class JobsListView extends Component { if (this.props.isManagementTable === true) { this.refreshJobSummaryList(true); } else { - // The advanced job wizard is still angularjs based and triggers - // broadcast events which it expects the jobs list to be subscribed to. + // The advanced job wizard is still angularjs based and triggers + // broadcast events which it expects the jobs list to be subscribed to. this.props.angularWrapperScope.$on('jobsUpdated', () => { this.refreshJobSummaryList(true); }); @@ -114,7 +105,7 @@ export class JobsListView extends Component { // so switch it on and set the interval to 30s timefilter.setRefreshInterval({ pause: false, - value: DEFAULT_REFRESH_INTERVAL_MS + value: DEFAULT_REFRESH_INTERVAL_MS, }); } @@ -124,7 +115,7 @@ export class JobsListView extends Component { initAutoRefreshUpdate() { // update the interval if it changes this.refreshIntervalSubscription = timefilter.getRefreshIntervalUpdate$().subscribe({ - next: () => this.setAutoRefresh() + next: () => this.setAutoRefresh(), }); } @@ -143,7 +134,7 @@ export class JobsListView extends Component { this.clearRefreshInterval(); if (interval >= MINIMUM_REFRESH_INTERVAL_MS) { this.blockRefresh = false; - jobsRefreshInterval = setInterval(() => (this.refreshJobSummaryList()), interval); + jobsRefreshInterval = setInterval(() => this.refreshJobSummaryList(), interval); } } @@ -159,13 +150,12 @@ export class JobsListView extends Component { } } - toggleRow = (jobId) => { + toggleRow = jobId => { if (this.state.itemIdToExpandedRowMap[jobId]) { const itemIdToExpandedRowMap = { ...this.state.itemIdToExpandedRowMap }; delete itemIdToExpandedRowMap[jobId]; this.setState({ itemIdToExpandedRowMap }); } else { - let itemIdToExpandedRowMap = { ...this.state.itemIdToExpandedRowMap }; if (this.state.fullJobsList[jobId] !== undefined) { @@ -191,7 +181,7 @@ export class JobsListView extends Component { this.setState({ itemIdToExpandedRowMap }, () => { loadFullJob(jobId) - .then((job) => { + .then(job => { const fullJobsList = { ...this.state.fullJobsList }; fullJobsList[jobId] = job; this.setState({ fullJobsList }, () => { @@ -213,54 +203,54 @@ export class JobsListView extends Component { this.setState({ itemIdToExpandedRowMap }); }); }) - .catch((error) => { + .catch(error => { console.error(error); }); }); } - } + }; addUpdateFunction = (id, f) => { this.updateFunctions[id] = f; - } - removeUpdateFunction = (id) => { + }; + removeUpdateFunction = id => { delete this.updateFunctions[id]; - } + }; - setShowEditJobFlyoutFunction = (func) => { + setShowEditJobFlyoutFunction = func => { this.showEditJobFlyout = func; - } + }; unsetShowEditJobFlyoutFunction = () => { this.showEditJobFlyout = () => {}; - } + }; - setShowDeleteJobModalFunction = (func) => { + setShowDeleteJobModalFunction = func => { this.showDeleteJobModal = func; - } + }; unsetShowDeleteJobModalFunction = () => { this.showDeleteJobModal = () => {}; - } + }; - setShowStartDatafeedModalFunction = (func) => { + setShowStartDatafeedModalFunction = func => { this.showStartDatafeedModal = func; - } + }; unsetShowStartDatafeedModalFunction = () => { this.showStartDatafeedModal = () => {}; - } + }; - setShowCreateWatchFlyoutFunction = (func) => { + setShowCreateWatchFlyoutFunction = func => { this.showCreateWatchFlyout = func; - } + }; unsetShowCreateWatchFlyoutFunction = () => { this.showCreateWatchFlyout = () => {}; - } + }; getShowCreateWatchFlyoutFunction = () => { return this.showCreateWatchFlyout; - } + }; - selectJobChange = (selectedJobs) => { + selectJobChange = selectedJobs => { this.setState({ selectedJobs }); - } + }; refreshSelectedJobs() { const selectedJobsIds = this.state.selectedJobs.map(j => j.id); @@ -275,24 +265,23 @@ export class JobsListView extends Component { this.setState({ selectedJobs }); } - setFilters = (filterClauses) => { + setFilters = filterClauses => { const filteredJobsSummaryList = filterJobs(this.state.jobsSummaryList, filterClauses); this.setState({ filteredJobsSummaryList, filterClauses }, () => { this.refreshSelectedJobs(); }); - } + }; onRefreshClick = () => { this.setState({ isRefreshing: true }); this.refreshJobSummaryList(true); - } + }; isDoneRefreshing = () => { this.setState({ isRefreshing: false }); - } + }; async refreshJobSummaryList(forceRefresh = false) { if (forceRefresh === true || this.blockRefresh === false) { - // Set loading to true for jobs_list table for initial job loading if (this.state.loading === null) { this.setState({ loading: true }); @@ -302,24 +291,27 @@ export class JobsListView extends Component { try { const jobs = await ml.jobs.jobsSummary(expandedJobsIds); const fullJobsList = {}; - const jobsSummaryList = jobs.map((job) => { + const jobsSummaryList = jobs.map(job => { if (job.fullJob !== undefined) { fullJobsList[job.id] = job.fullJob; delete job.fullJob; } - job.latestTimestampSortValue = (job.latestTimestampMs || 0); + job.latestTimestampSortValue = job.latestTimestampMs || 0; return job; }); const filteredJobsSummaryList = filterJobs(jobsSummaryList, this.state.filterClauses); - this.setState({ jobsSummaryList, filteredJobsSummaryList, fullJobsList, loading: false }, () => { - this.refreshSelectedJobs(); - }); + this.setState( + { jobsSummaryList, filteredJobsSummaryList, fullJobsList, loading: false }, + () => { + this.refreshSelectedJobs(); + } + ); - Object.keys(this.updateFunctions).forEach((j) => { + Object.keys(this.updateFunctions).forEach(j => { this.updateFunctions[j].setState({ job: fullJobsList[j] }); }); - jobs.forEach((job) => { + jobs.forEach(job => { if (job.deleting && this.state.itemIdToExpandedRowMap[job.id]) { this.toggleRow(job.id); } @@ -342,7 +334,8 @@ export class JobsListView extends Component { async checkDeletingJobTasks(forceRefresh = false) { const { jobIds: taskJobIds } = await ml.jobs.deletingJobTasks(); - const taskListHasChanged = (isEqual(taskJobIds.sort(), this.state.deletingJobIds.sort()) === false); + const taskListHasChanged = + isEqual(taskJobIds.sort(), this.state.deletingJobIds.sort()) === false; this.setState({ deletingJobIds: taskJobIds, @@ -363,7 +356,13 @@ export class JobsListView extends Component { } renderManagementJobsListComponents() { - const { loading, itemIdToExpandedRowMap, filteredJobsSummaryList, fullJobsList, selectedJobs } = this.state; + const { + loading, + itemIdToExpandedRowMap, + filteredJobsSummaryList, + fullJobsList, + selectedJobs, + } = this.state; return (
@@ -442,38 +441,51 @@ export class JobsListView extends Component { const { isManagementTable } = this.props; return ( - - -
- - -
-
- +
+ {!isManagementTable && ( + <> + +

+ +

+
+ + + )} + + + + + + + + + + + + + + + {!isManagementTable && ( - + - {isManagementTable === undefined && - - - } - -
-
- -
- - - - { !isManagementTable && this.renderJobsListComponents() } - { isManagementTable && this.renderManagementJobsListComponents() } - - + )} + + + + + + + {!isManagementTable && this.renderJobsListComponents()} + {isManagementTable && this.renderManagementJobsListComponents()} +
); } } diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/jobs.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/jobs.js index 188048d2d2f05..21c184cdcd298 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/jobs.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/jobs.js @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React from 'react'; import { NavigationMenu } from '../../components/navigation_menu'; import { JobsListView } from './components/jobs_list_view'; export const JobsPage = (props) => ( - + <> - + ); diff --git a/x-pack/legacy/plugins/ml/public/management/jobs_list/components/jobs_list_page/_buttons.scss b/x-pack/legacy/plugins/ml/public/management/jobs_list/components/jobs_list_page/_buttons.scss index 80b3ad5a390d2..d235c832ffaf1 100644 --- a/x-pack/legacy/plugins/ml/public/management/jobs_list/components/jobs_list_page/_buttons.scss +++ b/x-pack/legacy/plugins/ml/public/management/jobs_list/components/jobs_list_page/_buttons.scss @@ -1,10 +1,6 @@ // Refresh button style -.job-buttons-container { - float: right; -} - .managementJobsList{ clear: both; } diff --git a/x-pack/legacy/plugins/ml/public/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/legacy/plugins/ml/public/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 99e0a240d32d1..e3188c0892580 100644 --- a/x-pack/legacy/plugins/ml/public/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/legacy/plugins/ml/public/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -23,7 +23,6 @@ import { metadata } from 'ui/metadata'; // @ts-ignore undeclared module import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_view'; import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; -import { RefreshAnalyticsListButton } from '../../../../data_frame_analytics/pages/analytics_management/components/refresh_analytics_list_button'; interface Props { isMlEnabledInSpace: boolean; @@ -56,10 +55,6 @@ function getTabs(isMlEnabledInSpace: boolean): Tab[] { content: ( - - - - = ({ jobCreationDisabled }) => { const [analytics, setAnalytics] = useState([]); + const [analyticsStats, setAnalyticsStats] = useState( + undefined + ); const [errorMessage, setErrorMessage] = useState(undefined); const [isInitialized, setIsInitialized] = useState(false); - const getAnalytics = getAnalyticsFactory(setAnalytics, setErrorMessage, setIsInitialized, false); + const getAnalytics = getAnalyticsFactory( + setAnalytics, + setAnalyticsStats, + setErrorMessage, + setIsInitialized, + false + ); useEffect(() => { getAnalytics(true); @@ -38,21 +52,19 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { }; const errorDisplay = ( - - -
-          {errorMessage && errorMessage.message !== undefined
-            ? errorMessage.message
-            : JSON.stringify(errorMessage)}
-        
-
-
+ +
+        {errorMessage && errorMessage.message !== undefined
+          ? errorMessage.message
+          : JSON.stringify(errorMessage)}
+      
+
); const panelClass = isInitialized === false ? 'mlOverviewPanel__isLoading' : 'mlOverviewPanel'; @@ -75,13 +87,11 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { } body={ - -

- {i18n.translate('xpack.ml.overview.analyticsList.emptyPromptText', { - defaultMessage: `Data frame analytics enable you to perform different analyses of your data and annotate it with the results. The analytics job stores the annotated data, as well as a copy of the source data, in a new index.`, - })} -

-
+

+ {i18n.translate('xpack.ml.overview.analyticsList.emptyPromptText', { + defaultMessage: `Data frame analytics enable you to perform different analyses of your data and annotate it with the results. The analytics job stores the annotated data, as well as a copy of the source data, in a new index.`, + })} +

} actions={ = ({ jobCreationDisabled }) => { /> )} {isInitialized === true && analytics.length > 0 && ( - + <> + + + +

+ {i18n.translate('xpack.ml.overview.analyticsList.PanelTitle', { + defaultMessage: 'Analytics', + })} +

+
+
+ {analyticsStats !== undefined && ( + + + + )} +
+
@@ -114,7 +141,7 @@ export const AnalyticsPanel: FC = ({ jobCreationDisabled }) => { })}
-
+ )} ); diff --git a/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_stats_bar.tsx b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_stats_bar.tsx deleted file mode 100644 index 19a907ff8e899..0000000000000 --- a/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_stats_bar.tsx +++ /dev/null @@ -1,89 +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 React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; -import { StatsBar, AnalyticStatsBarStats } from '../../../components/stats_bar'; -import { - isDataFrameAnalyticsFailed, - isDataFrameAnalyticsRunning, - isDataFrameAnalyticsStopped, - DataFrameAnalyticsListRow, -} from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; - -function getAnalyticsStats(analyticsList: any[]) { - const analyticsStats = { - total: { - label: i18n.translate('xpack.ml.overview.statsBar.totalAnalyticsLabel', { - defaultMessage: 'Total analytics jobs', - }), - value: 0, - show: true, - }, - started: { - label: i18n.translate('xpack.ml.overview.statsBar.runningAnalyticsLabel', { - defaultMessage: 'Running', - }), - value: 0, - show: true, - }, - stopped: { - label: i18n.translate('xpack.ml.overview.statsBar.stoppedAnalyticsLabel', { - defaultMessage: 'Stopped', - }), - value: 0, - show: true, - }, - failed: { - label: i18n.translate('xpack.ml.overview.statsBar.failedAnalyticsLabel', { - defaultMessage: 'Failed', - }), - value: 0, - show: false, - }, - }; - - if (analyticsList === undefined) { - return analyticsStats; - } - - let failedJobs = 0; - let startedJobs = 0; - let stoppedJobs = 0; - - analyticsList.forEach(job => { - if (isDataFrameAnalyticsFailed(job.stats.state)) { - failedJobs++; - } else if (isDataFrameAnalyticsRunning(job.stats.state)) { - startedJobs++; - } else if (isDataFrameAnalyticsStopped(job.stats.state)) { - stoppedJobs++; - } - }); - - analyticsStats.total.value = analyticsList.length; - analyticsStats.started.value = startedJobs; - analyticsStats.stopped.value = stoppedJobs; - - if (failedJobs !== 0) { - analyticsStats.failed.value = failedJobs; - analyticsStats.failed.show = true; - } else { - analyticsStats.failed.show = false; - } - - return analyticsStats; -} - -interface Props { - analyticsList: DataFrameAnalyticsListRow[]; -} - -export const AnalyticsStatsBar: FC = ({ analyticsList }) => { - const analyticsStats: AnalyticStatsBarStats = getAnalyticsStats(analyticsList); - - return ; -}; diff --git a/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/table.tsx b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/table.tsx index 1ac767ab97700..787f0a467f44d 100644 --- a/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/table.tsx +++ b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/table.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useState } from 'react'; -import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import React, { FC, useState } from 'react'; +import { EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { MlInMemoryTable, @@ -25,7 +25,6 @@ import { } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/columns'; import { AnalyticsViewAction } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/actions'; import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils'; -import { AnalyticsStatsBar } from './analytics_stats_bar'; interface Props { items: any[]; @@ -114,36 +113,19 @@ export const AnalyticsTable: FC = ({ items }) => { }; return ( - - - - -

- {i18n.translate('xpack.ml.overview.analyticsList.PanelTitle', { - defaultMessage: 'Analytics', - })} -

-
-
- - - -
- - -
+ ); }; diff --git a/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts b/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts index 22062331bb380..4f042c638471d 100644 --- a/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts @@ -11,6 +11,7 @@ import { PrivilegesResponse } from '../../../common/types/privileges'; import { MlSummaryJobs } from '../../../common/types/jobs'; import { MlServerDefaults, MlServerLimits } from '../../jobs/new_job_new/utils/new_job_defaults'; import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; +import { DataFrameAnalyticsStats } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; // TODO This is not a complete representation of all methods of `ml.*`. // It just satisfies needs for other parts of the code area which use @@ -65,7 +66,7 @@ declare interface Ml { dataFrameAnalytics: { getDataFrameAnalytics(analyticsId?: string): Promise; - getDataFrameAnalyticsStats(analyticsId?: string): Promise; + getDataFrameAnalyticsStats(analyticsId?: string): Promise; createDataFrameAnalytics(analyticsId: string, analyticsConfig: any): Promise; evaluateDataFrameAnalytics(evaluateConfig: any): Promise; deleteDataFrameAnalytics(analyticsId: string): Promise; @@ -155,3 +156,19 @@ declare interface Ml { } declare const ml: Ml; + +export interface GetDataFrameAnalyticsStatsResponseOk { + node_failures?: object; + count: number; + data_frame_analytics: DataFrameAnalyticsStats[]; +} + +export interface GetDataFrameAnalyticsStatsResponseError { + statusCode: number; + error: string; + message: string; +} + +export type GetDataFrameAnalyticsStatsResponse = + | GetDataFrameAnalyticsStatsResponseOk + | GetDataFrameAnalyticsStatsResponseError; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts index 749ab3c50f1c0..9b64e896dad18 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts @@ -25,6 +25,16 @@ export const getCustomLogo = async ({ // We use the basePath from the saved job, which we'll have post spaces being implemented; // or we use the server base path, which uses the default space getBasePath: () => job.basePath || serverBasePath, + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, }; const savedObjects = server.savedObjects; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.js b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.js index 2e826f51e7218..ff49daced4a65 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.js +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.js @@ -53,6 +53,16 @@ function executeJobFn(server) { // We use the basePath from the saved job, which we'll have post spaces being implemented; // or we use the server base path, which uses the default space getBasePath: () => basePath || serverBasePath, + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, }; const callEndpoint = (endpoint, clientParams = {}, options = {}) => { diff --git a/x-pack/legacy/plugins/reporting/server/lib/get_user.js b/x-pack/legacy/plugins/reporting/server/lib/get_user.js index 70af19239df87..2c4f3bcb2dd36 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/get_user.js +++ b/x-pack/legacy/plugins/reporting/server/lib/get_user.js @@ -5,23 +5,19 @@ */ import { oncePerServer } from './once_per_server'; -import { getClient as getShieldClient } from '../../../../server/lib/get_client_shield'; function getUserFn(server) { - const callShieldWithRequest = getShieldClient(server).callWithRequest; - - return async function getUser(request) { - const xpackInfo = server.plugins.xpack_main.info; - if (xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security').isEnabled()) { - try { - return await callShieldWithRequest(request, 'shield.authenticate'); - } catch (err) { - server.log(['reporting', 'getUser', 'debug'], err); - return null; - } + return async request => { + if (!server.plugins.security) { + return null; } - return null; + try { + return await server.plugins.security.getUser(request); + } catch (err) { + server.log(['reporting', 'getUser', 'debug'], err); + return null; + } }; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/__tests__/authorized_user_pre_routing.test.js b/x-pack/legacy/plugins/reporting/server/routes/lib/__tests__/authorized_user_pre_routing.test.js index 42b2019507fe9..c9c93727fd45f 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/__tests__/authorized_user_pre_routing.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/__tests__/authorized_user_pre_routing.test.js @@ -15,7 +15,7 @@ describe('authorized_user_pre_routing', function () { // so createMockServer reuses the same 'instance' of the server and overwrites // the properties to contain different values const createMockServer = (function () { - const callWithRequestStub = sinon.stub(); + const getUserStub = sinon.stub(); let mockConfig; const mockServer = { @@ -30,13 +30,7 @@ describe('authorized_user_pre_routing', function () { log: function () {}, plugins: { xpack_main: {}, - elasticsearch: { - createCluster: function () { - return { - callWithRequest: callWithRequestStub - }; - } - } + security: { getUser: getUserStub }, } }; @@ -57,8 +51,8 @@ describe('authorized_user_pre_routing', function () { } }; - callWithRequestStub.resetHistory(); - callWithRequestStub.returns(Promise.resolve(user)); + getUserStub.resetHistory(); + getUserStub.resolves(user); return mockServer; }; }()); diff --git a/x-pack/legacy/plugins/security/common/constants.ts b/x-pack/legacy/plugins/security/common/constants.ts index 2ec429b4d9c4c..08e49ad995550 100644 --- a/x-pack/legacy/plugins/security/common/constants.ts +++ b/x-pack/legacy/plugins/security/common/constants.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export const GLOBAL_RESOURCE = '*'; -export const IGNORED_TYPES = ['space']; -export const APPLICATION_PREFIX = 'kibana-'; -export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; export const INTERNAL_API_BASE_PATH = '/internal/security'; diff --git a/x-pack/legacy/plugins/security/common/login_state.ts b/x-pack/legacy/plugins/security/common/login_state.ts index b41fb85214c66..b1eb3d61fe5f3 100644 --- a/x-pack/legacy/plugins/security/common/login_state.ts +++ b/x-pack/legacy/plugins/security/common/login_state.ts @@ -9,5 +9,4 @@ export type LoginLayout = 'form' | 'error-es-unavailable' | 'error-xpack-unavail export interface LoginState { layout: LoginLayout; allowLogin: boolean; - loginMessage: string; } diff --git a/x-pack/legacy/plugins/security/common/model/index.ts b/x-pack/legacy/plugins/security/common/model/index.ts index 19243c25fef7e..6c2976815559b 100644 --- a/x-pack/legacy/plugins/security/common/model/index.ts +++ b/x-pack/legacy/plugins/security/common/model/index.ts @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role'; -export { FeaturesPrivileges } from './features_privileges'; -export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; -export { KibanaPrivileges } from './kibana_privileges'; export { ApiKey } from './api_key'; -export { User, EditUser, getUserDisplayName } from '../../../../../plugins/security/common/model'; export { AuthenticatedUser, + BuiltinESPrivileges, + EditUser, + FeaturesPrivileges, + KibanaPrivileges, + RawKibanaFeaturePrivileges, + RawKibanaPrivileges, + Role, + RoleIndexPrivilege, + RoleKibanaPrivilege, + User, canUserChangePassword, + getUserDisplayName, } from '../../../../../plugins/security/common/model'; -export { BuiltinESPrivileges } from './builtin_es_privileges'; diff --git a/x-pack/legacy/plugins/security/index.d.ts b/x-pack/legacy/plugins/security/index.d.ts index a0d18dd3cbb99..18284c8be689a 100644 --- a/x-pack/legacy/plugins/security/index.d.ts +++ b/x-pack/legacy/plugins/security/index.d.ts @@ -6,12 +6,10 @@ import { Legacy } from 'kibana'; import { AuthenticatedUser } from './common/model'; -import { AuthorizationService } from './server/lib/authorization/service'; /** * Public interface of the security plugin. */ export interface SecurityPlugin { - authorization: Readonly; getUser: (request: Legacy.Request) => Promise; } diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index f9e82f575ce2e..c098e3e67a6d9 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -8,29 +8,13 @@ import { resolve } from 'path'; import { initAuthenticateApi } from './server/routes/api/v1/authenticate'; import { initUsersApi } from './server/routes/api/v1/users'; import { initApiKeysApi } from './server/routes/api/v1/api_keys'; -import { initExternalRolesApi } from './server/routes/api/external/roles'; -import { initPrivilegesApi } from './server/routes/api/external/privileges'; import { initIndicesApi } from './server/routes/api/v1/indices'; -import { initGetBuiltinPrivilegesApi } from './server/routes/api/v1/builtin_privileges'; import { initOverwrittenSessionView } from './server/routes/views/overwritten_session'; import { initLoginView } from './server/routes/views/login'; import { initLogoutView } from './server/routes/views/logout'; import { initLoggedOutView } from './server/routes/views/logged_out'; -import { checkLicense } from './server/lib/check_license'; -import { SecurityAuditLogger } from './server/lib/audit_logger'; import { AuditLogger } from '../../server/lib/audit_logger'; -import { - createAuthorizationService, - disableUICapabilitesFactory, - initAPIAuthorization, - initAppAuthorization, - registerPrivilegesWithCluster, - validateFeaturePrivileges -} from './server/lib/authorization'; import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; -import { SecureSavedObjectsClientWrapper } from './server/lib/saved_objects_client/secure_saved_objects_client_wrapper'; -import { deepFreeze } from './server/lib/deep_freeze'; -import { createOptionalPlugin } from '../../server/lib/optional_plugin'; import { KibanaRequest } from '../../../../src/core/server'; import { createCSPRuleString } from '../../../../src/legacy/server/csp'; @@ -103,23 +87,22 @@ export const security = (kibana) => new kibana.Plugin({ } return { - secureCookies: securityPlugin.config.secureCookies, - sessionTimeout: securityPlugin.config.sessionTimeout, + secureCookies: securityPlugin.__legacyCompat.config.secureCookies, + sessionTimeout: securityPlugin.__legacyCompat.config.sessionTimeout, enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), }; }, }, async postInit(server) { - const plugin = this; - - const xpackMainPlugin = server.plugins.xpack_main; + const securityPlugin = server.newPlatform.setup.plugins.security; + if (!securityPlugin) { + throw new Error('New Platform XPack Security plugin is not available.'); + } - watchStatusAndLicenseToInitialize(xpackMainPlugin, plugin, async (license) => { - if (license.allowRbac) { - const { security } = server.plugins; - await validateFeaturePrivileges(security.authorization.actions, xpackMainPlugin.getFeatures()); - await registerPrivilegesWithCluster(server); + watchStatusAndLicenseToInitialize(server.plugins.xpack_main, this, async () => { + if (securityPlugin.__legacyCompat.license.getFeatures().allowRbac) { + await securityPlugin.__legacyCompat.registerPrivilegesWithCluster(); } }); }, @@ -131,110 +114,46 @@ export const security = (kibana) => new kibana.Plugin({ } const config = server.config(); - const xpackMainPlugin = server.plugins.xpack_main; - const xpackInfo = xpackMainPlugin.info; - securityPlugin.registerLegacyAPI({ - xpackInfo, + const xpackInfo = server.plugins.xpack_main.info; + securityPlugin.__legacyCompat.registerLegacyAPI({ + savedObjects: server.savedObjects, + auditLogger: new AuditLogger(server, 'security', config, xpackInfo), isSystemAPIRequest: server.plugins.kibana.systemApi.isSystemApiRequest.bind( server.plugins.kibana.systemApi ), + capabilities: { registerCapabilitiesModifier: server.registerCapabilitiesModifier }, cspRules: createCSPRuleString(config.get('csp.rules')), + kibanaIndexName: config.get('kibana.index'), }); - const plugin = this; - const xpackInfoFeature = xpackInfo.feature(plugin.id); - - // Register a function that is called whenever the xpack info changes, - // to re-compute the license check results for this plugin - xpackInfoFeature.registerLicenseCheckResultsGenerator(checkLicense); + // Legacy xPack Info endpoint returns whatever we return in a callback for `registerLicenseCheckResultsGenerator` + // and the result is consumed by the legacy plugins all over the place, so we should keep it here for now. We assume + // that when legacy callback is called license has been already propagated to the new platform security plugin and + // features are up to date. + xpackInfo.feature(this.id).registerLicenseCheckResultsGenerator( + () => securityPlugin.__legacyCompat.license.getFeatures() + ); server.expose({ getUser: request => securityPlugin.authc.getCurrentUser(KibanaRequest.from(request)) }); - const { savedObjects } = server; - - const spaces = createOptionalPlugin(config, 'xpack.spaces', server.plugins, 'spaces'); - - // exposes server.plugins.security.authorization - const authorization = createAuthorizationService(server, xpackInfoFeature, xpackMainPlugin, spaces); - server.expose('authorization', deepFreeze(authorization)); - - const auditLogger = new SecurityAuditLogger(new AuditLogger(server, 'security', server.config(), xpackInfo)); - - savedObjects.setScopedSavedObjectsClientFactory(({ - request, - }) => { - const adminCluster = server.plugins.elasticsearch.getCluster('admin'); - const { callWithRequest, callWithInternalUser } = adminCluster; - const callCluster = (...args) => callWithRequest(request, ...args); - - if (authorization.mode.useRbacForRequest(request)) { - const internalRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser); - return new savedObjects.SavedObjectsClient(internalRepository); - } - - const callWithRequestRepository = savedObjects.getSavedObjectsRepository(callCluster); - return new savedObjects.SavedObjectsClient(callWithRequestRepository); - }); - - savedObjects.addScopedSavedObjectsClientWrapperFactory(Number.MAX_SAFE_INTEGER - 1, 'security', ({ client, request }) => { - if (authorization.mode.useRbacForRequest(request)) { - return new SecureSavedObjectsClientWrapper({ - actions: authorization.actions, - auditLogger, - baseClient: client, - checkSavedObjectsPrivilegesWithRequest: authorization.checkSavedObjectsPrivilegesWithRequest, - errors: savedObjects.SavedObjectsClient.errors, - request, - savedObjectTypes: savedObjects.types, - }); - } - - return client; - }); - initAuthenticateApi(securityPlugin, server); - initAPIAuthorization(server, authorization); - initAppAuthorization(server, xpackMainPlugin, authorization); initUsersApi(securityPlugin, server); initApiKeysApi(server); - initExternalRolesApi(server); initIndicesApi(server); - initPrivilegesApi(server); - initGetBuiltinPrivilegesApi(server); - initLoginView(securityPlugin, server, xpackMainPlugin); + initLoginView(securityPlugin, server); initLogoutView(server); initLoggedOutView(securityPlugin, server); initOverwrittenSessionView(server); server.injectUiAppVars('login', () => { - - const { showLogin, loginMessage, allowLogin, layout = 'form' } = xpackInfo.feature(plugin.id).getLicenseCheckResults() || {}; - + const { showLogin, allowLogin, layout = 'form' } = securityPlugin.__legacyCompat.license.getFeatures(); return { loginState: { showLogin, allowLogin, - loginMessage, layout, } }; }); - - server.registerCapabilitiesModifier((request, uiCapabilities) => { - // if we have a license which doesn't enable security, or we're a legacy user - // we shouldn't disable any ui capabilities - const { authorization } = server.plugins.security; - if (!authorization.mode.useRbacForRequest(request)) { - return uiCapabilities; - } - - const disableUICapabilites = disableUICapabilitesFactory(server, request); - // if we're an anonymous route, we disable all ui capabilities - if (request.route.settings.auth === false) { - return disableUICapabilites.all(uiCapabilities); - } - - return disableUICapabilites.usingPrivileges(uiCapabilities); - }); } }); diff --git a/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts b/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts index f2e2c4bc1be99..aa7096d141f43 100644 --- a/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts +++ b/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts @@ -6,7 +6,10 @@ import _ from 'lodash'; import { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../common/model'; -import { areActionsFullyCovered, compareActions } from '../../../common/privilege_calculator_utils'; +import { + areActionsFullyCovered, + compareActions, +} from '../../../../../../plugins/security/common/privilege_calculator_utils'; import { NO_PRIVILEGE_VALUE } from '../../views/management/edit_role/lib/constants'; import { isGlobalPrivilegeDefinition } from '../privilege_utils'; import { diff --git a/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_base_privilege_calculator.ts b/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_base_privilege_calculator.ts index 37ed5b6c02e9b..dd4e91aa4037a 100644 --- a/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_base_privilege_calculator.ts +++ b/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_base_privilege_calculator.ts @@ -6,7 +6,7 @@ import _ from 'lodash'; import { KibanaPrivileges, RoleKibanaPrivilege } from '../../../common/model'; -import { compareActions } from '../../../common/privilege_calculator_utils'; +import { compareActions } from '../../../../../../plugins/security/common/privilege_calculator_utils'; import { NO_PRIVILEGE_VALUE } from '../../views/management/edit_role/lib/constants'; import { isGlobalPrivilegeDefinition } from '../privilege_utils'; import { PRIVILEGE_SOURCE, PrivilegeExplanation } from './kibana_privilege_calculator_types'; diff --git a/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts b/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts index 597a05a5372b1..ed18b5d1e89a7 100644 --- a/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts +++ b/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts @@ -6,7 +6,7 @@ import _ from 'lodash'; import { FeaturesPrivileges, KibanaPrivileges, RoleKibanaPrivilege } from '../../../common/model'; -import { areActionsFullyCovered } from '../../../common/privilege_calculator_utils'; +import { areActionsFullyCovered } from '../../../../../../plugins/security/common/privilege_calculator_utils'; import { NO_PRIVILEGE_VALUE } from '../../views/management/edit_role/lib/constants'; import { isGlobalPrivilegeDefinition } from '../privilege_utils'; import { diff --git a/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts b/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts index 3d8a0698465ab..aee6943214c57 100644 --- a/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts +++ b/x-pack/legacy/plugins/security/public/lib/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts @@ -5,7 +5,7 @@ */ import _ from 'lodash'; import { FeaturesPrivileges, KibanaPrivileges, Role } from '../../../common/model'; -import { compareActions } from '../../../common/privilege_calculator_utils'; +import { compareActions } from '../../../../../../plugins/security/common/privilege_calculator_utils'; import { copyRole } from '../../lib/role_utils'; import { KibanaPrivilegeCalculator } from './kibana_privilege_calculator'; diff --git a/x-pack/legacy/plugins/security/public/lib/roles_api.ts b/x-pack/legacy/plugins/security/public/lib/roles_api.ts index b83e9369a37ea..20c1491ccaac6 100644 --- a/x-pack/legacy/plugins/security/public/lib/roles_api.ts +++ b/x-pack/legacy/plugins/security/public/lib/roles_api.ts @@ -5,7 +5,7 @@ */ import { kfetch } from 'ui/kfetch'; -import { Role } from '../../common/model/role'; +import { Role } from '../../common/model'; export class RolesApi { public static async getRoles(): Promise { diff --git a/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.test.tsx b/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.test.tsx index 21c1dacb06d42..664c9f2a046c0 100644 --- a/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.test.tsx +++ b/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.test.tsx @@ -33,7 +33,6 @@ const createLoginState = (options?: Partial) => { return { allowLogin: true, layout: 'form', - loginMessage: '', ...options, } as LoginState; }; diff --git a/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap b/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap index 852cbb26a1dcf..fc33c6e0a82cc 100644 --- a/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap +++ b/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap @@ -389,7 +389,6 @@ exports[`LoginPage enabled form state renders as expected 1`] = ` Object { "allowLogin": true, "layout": "form", - "loginMessage": "", } } next="" diff --git a/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx b/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx index 8d7bd0e10352a..af91d12624c64 100644 --- a/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx +++ b/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx @@ -32,7 +32,6 @@ const createLoginState = (options?: Partial) => { return { allowLogin: true, layout: 'form', - loginMessage: '', ...options, } as LoginState; }; diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_role/components/edit_role_page.test.tsx b/x-pack/legacy/plugins/security/public/views/management/edit_role/components/edit_role_page.test.tsx index 75f9520cef64b..cb60b773f92e0 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_role/components/edit_role_page.test.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/edit_role/components/edit_role_page.test.tsx @@ -10,9 +10,9 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { UICapabilities } from 'ui/capabilities'; import { Space } from '../../../../../../spaces/common/model/space'; import { Feature } from '../../../../../../../../plugins/features/server'; +import { Actions } from '../../../../../../../../plugins/security/server/authorization/actions'; +import { privilegesFactory } from '../../../../../../../../plugins/security/server/authorization/privileges'; import { RawKibanaPrivileges, Role } from '../../../../../common/model'; -import { actionsFactory } from '../../../../../server/lib/authorization/actions'; -import { privilegesFactory } from '../../../../../server/lib/authorization/privileges'; import { EditRolePage } from './edit_role_page'; import { SimplePrivilegeSection } from './privileges/kibana/simple_privilege_section'; import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section'; @@ -56,13 +56,9 @@ const buildFeatures = () => { }; const buildRawKibanaPrivileges = () => { - const xpackMainPlugin = { + return privilegesFactory(new Actions('unit_test_version'), { getFeatures: () => buildFeatures(), - }; - - const actions = actionsFactory({ get: jest.fn(() => 'unit_test_version') }); - - return privilegesFactory(actions, xpackMainPlugin as any).get(); + }).get(); }; const buildBuiltinESPrivileges = () => { diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js b/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js index b1cf7e9f46756..24e304b0010d0 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js +++ b/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js @@ -88,7 +88,7 @@ const routeDefinition = (action) => ({ return kfetch({ method: 'get', pathname: '/api/security/privileges', query: { includeActions: true } }); }, builtinESPrivileges() { - return kfetch({ method: 'get', pathname: '/api/security/v1/esPrivileges/builtin' }); + return kfetch({ method: 'get', pathname: '/internal/security/esPrivileges/builtin' }); }, features() { return kfetch({ method: 'get', pathname: '/api/features' }).catch(e => { diff --git a/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/roles_grid_page.tsx b/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/roles_grid_page.tsx index 9a1d029273c19..d0645f85946db 100644 --- a/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/roles_grid_page.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/roles_grid/components/roles_grid_page.tsx @@ -21,7 +21,7 @@ import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { Component } from 'react'; import { toastNotifications } from 'ui/notify'; -import { Role } from '../../../../../common/model/role'; +import { Role } from '../../../../../common/model'; import { isRoleEnabled, isReadOnlyRole, isReservedRole } from '../../../../lib/role_utils'; import { RolesApi } from '../../../../lib/roles_api'; import { ConfirmDelete } from './confirm_delete'; diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/h.js b/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/h.js deleted file mode 100644 index 31355b8795754..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/__fixtures__/h.js +++ /dev/null @@ -1,21 +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 { stub } from 'sinon'; - -export function hFixture() { - const h = {}; - - Object.assign(h, { - authenticated: stub().returns(h), - continue: 'continue value', - redirect: stub().returns(h), - unstate: stub().returns(h), - takeover: stub().returns(h) - }); - - return h; -} diff --git a/x-pack/legacy/plugins/security/server/lib/__tests__/check_license.js b/x-pack/legacy/plugins/security/server/lib/__tests__/check_license.js deleted file mode 100644 index ad5c59f36eb44..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/__tests__/check_license.js +++ /dev/null @@ -1,133 +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 expect from '@kbn/expect'; -import sinon from 'sinon'; -import { checkLicense } from '../check_license'; - -describe('check_license', function () { - - let mockXPackInfo; - - beforeEach(function () { - mockXPackInfo = { - isAvailable: sinon.stub(), - isXpackUnavailable: sinon.stub(), - feature: sinon.stub(), - license: sinon.stub({ - isOneOf() { }, - }) - }; - - mockXPackInfo.isAvailable.returns(true); - }); - - it('should display error when ES is unavailable', () => { - mockXPackInfo.isAvailable.returns(false); - mockXPackInfo.isXpackUnavailable.returns(false); - - const licenseCheckResults = checkLicense(mockXPackInfo); - expect(licenseCheckResults).to.be.eql({ - showLogin: true, - allowLogin: false, - showLinks: false, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - layout: 'error-es-unavailable', - allowRbac: false, - }); - }); - - it('should display error when X-Pack is unavailable', () => { - mockXPackInfo.isAvailable.returns(false); - mockXPackInfo.isXpackUnavailable.returns(true); - - const licenseCheckResults = checkLicense(mockXPackInfo); - expect(licenseCheckResults).to.be.eql({ - showLogin: true, - allowLogin: false, - showLinks: false, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - layout: 'error-xpack-unavailable', - allowRbac: false, - }); - }); - - - it('should show login page and other security elements if license is basic and security is enabled.', () => { - mockXPackInfo.license.isOneOf.withArgs(['basic']).returns(true); - mockXPackInfo.license.isOneOf.withArgs(['platinum', 'trial']).returns(false); - mockXPackInfo.feature.withArgs('security').returns({ - isEnabled: () => { return true; } - }); - - const licenseCheckResults = checkLicense(mockXPackInfo); - expect(licenseCheckResults).to.be.eql({ - showLogin: true, - allowLogin: true, - showLinks: true, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - allowRbac: true - }); - }); - - it('should not show login page or other security elements if security is disabled in Elasticsearch.', () => { - mockXPackInfo.license.isOneOf.withArgs(['basic']).returns(false); - mockXPackInfo.feature.withArgs('security').returns({ - isEnabled: () => { return false; } - }); - - const licenseCheckResults = checkLicense(mockXPackInfo); - expect(licenseCheckResults).to.be.eql({ - showLogin: false, - allowLogin: false, - showLinks: false, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - allowRbac: false, - linksMessage: 'Access is denied because Security is disabled in Elasticsearch.' - }); - }); - - it('should allow to login and allow RBAC but forbid document level security if license is not platinum or trial.', () => { - mockXPackInfo.license.isOneOf - .returns(false) - .withArgs(['platinum', 'trial']).returns(false); - mockXPackInfo.feature.withArgs('security').returns({ - isEnabled: () => { return true; } - }); - - expect(checkLicense(mockXPackInfo)).to.be.eql({ - showLogin: true, - allowLogin: true, - showLinks: true, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - allowRbac: true, - }); - }); - - it('should allow to login, allow RBAC and document level security if license is platinum or trial.', () => { - mockXPackInfo.license.isOneOf - .returns(false) - .withArgs(['platinum', 'trial']).returns(true); - mockXPackInfo.feature.withArgs('security').returns({ - isEnabled: () => { return true; } - }); - - expect(checkLicense(mockXPackInfo)).to.be.eql({ - showLogin: true, - allowLogin: true, - showLinks: true, - allowRoleDocumentLevelSecurity: true, - allowRoleFieldLevelSecurity: true, - allowRbac: true, - }); - }); - -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/actions.test.ts b/x-pack/legacy/plugins/security/server/lib/authorization/actions/actions.test.ts deleted file mode 100644 index 11194d237e10c..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authorization/actions/actions.test.ts +++ /dev/null @@ -1,57 +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 { actionsFactory } from '.'; - -const createMockConfig = (settings: Record = {}) => { - const mockConfig = { - get: jest.fn(), - }; - - mockConfig.get.mockImplementation(key => settings[key]); - - return mockConfig; -}; - -describe('#constructor', () => { - test('requires version to be a string', () => { - const mockConfig = createMockConfig(); - - expect(() => actionsFactory(mockConfig)).toThrowErrorMatchingInlineSnapshot( - `"version should be a string"` - ); - }); - - test(`doesn't allow an empty string`, () => { - const mockConfig = createMockConfig({ 'pkg.version': '' }); - - expect(() => actionsFactory(mockConfig)).toThrowErrorMatchingInlineSnapshot( - `"version can't be an empty string"` - ); - }); -}); - -describe('#login', () => { - test('returns login:', () => { - const version = 'mock-version'; - const mockConfig = createMockConfig({ 'pkg.version': version }); - - const actions = actionsFactory(mockConfig); - - expect(actions.login).toBe('login:'); - }); -}); - -describe('#version', () => { - test("returns `version:${config.get('pkg.version')}`", () => { - const version = 'mock-version'; - const mockConfig = createMockConfig({ 'pkg.version': version }); - - const actions = actionsFactory(mockConfig); - - expect(actions.version).toBe(`version:${version}`); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/api_authorization.test.ts b/x-pack/legacy/plugins/security/server/lib/authorization/api_authorization.test.ts deleted file mode 100644 index 00d920c2f15b2..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authorization/api_authorization.test.ts +++ /dev/null @@ -1,193 +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 { Server } from 'hapi'; -import { AuthorizationService } from './service'; - -import { actionsFactory } from './actions'; -import { initAPIAuthorization } from './api_authorization'; - -const actions = actionsFactory({ - get(key: string) { - if (key === 'pkg.version') { - return `1.0.0-zeta1`; - } - - throw new Error(`Unexpected config key: ${key}`); - }, -}); - -describe('initAPIAuthorization', () => { - test(`route that doesn't start with "/api/" continues`, async () => { - const server = new Server(); - initAPIAuthorization(server, {} as AuthorizationService); - server.route([ - { - method: 'GET', - path: '/app/foo', - handler: () => { - return 'foo app response'; - }, - }, - ]); - const { result, statusCode } = await server.inject({ - method: 'GET', - url: '/app/foo', - }); - expect(result).toBe('foo app response'); - expect(statusCode).toBe(200); - }); - - test(`protected route that starts with "/api/", but "mode.useRbacForRequest()" returns false continues`, async () => { - const server = new Server(); - const mockAuthorizationService: AuthorizationService = { - mode: { - useRbacForRequest: jest.fn().mockReturnValue(false), - }, - } as any; - initAPIAuthorization(server, mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/api/foo', - options: { - tags: ['access:foo'], - }, - handler: () => { - return 'foo api response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/api/foo', - }); - expect(result).toBe('foo api response'); - expect(statusCode).toBe(200); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); - }); - - test(`unprotected route that starts with "/api/", but "mode.useRbacForRequest()" returns true continues`, async () => { - const server = new Server(); - const mockAuthorizationService: AuthorizationService = { - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAPIAuthorization(server, mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/api/foo', - options: { - tags: ['not-access:foo'], - }, - handler: () => { - return 'foo api response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/api/foo', - }); - expect(result).toBe('foo api response'); - expect(statusCode).toBe(200); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); - }); - - test(`protected route that starts with "/api/", "mode.useRbacForRequest()" returns true and user is authorized continues`, async () => { - const headers = { - authorization: 'foo', - }; - const server = new Server(); - const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: true }); - const mockAuthorizationService: AuthorizationService = { - actions, - checkPrivilegesDynamicallyWithRequest: (req: any) => { - // hapi conceals the actual "request" from us, so we make sure that the headers are passed to - // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with - expect(req.headers).toMatchObject(headers); - - return mockCheckPrivileges; - }, - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAPIAuthorization(server, mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/api/foo', - options: { - tags: ['access:foo'], - }, - handler: () => { - return 'foo api response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/api/foo', - headers, - }); - expect(result).toBe('foo api response'); - expect(statusCode).toBe(200); - expect(mockCheckPrivileges).toHaveBeenCalledWith([actions.api.get('foo')]); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); - }); - - test(`protected route that starts with "/api/", "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 404`, async () => { - const headers = { - authorization: 'foo', - }; - const server = new Server(); - const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: false }); - const mockAuthorizationService: AuthorizationService = { - actions, - checkPrivilegesDynamicallyWithRequest: (req: any) => { - // hapi conceals the actual "request" from us, so we make sure that the headers are passed to - // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with - expect(req.headers).toMatchObject(headers); - - return mockCheckPrivileges; - }, - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAPIAuthorization(server, mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/api/foo', - options: { - tags: ['access:foo'], - }, - handler: () => { - return 'foo api response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/api/foo', - headers, - }); - expect(result).toMatchInlineSnapshot(` -Object { - "error": "Not Found", - "message": "Not Found", - "statusCode": 404, -} -`); - expect(statusCode).toBe(404); - expect(mockCheckPrivileges).toHaveBeenCalledWith([actions.api.get('foo')]); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/app_authorization.test.ts b/x-pack/legacy/plugins/security/server/lib/authorization/app_authorization.test.ts deleted file mode 100644 index 52bc6de63146a..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authorization/app_authorization.test.ts +++ /dev/null @@ -1,198 +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 { Server } from 'hapi'; -import { AuthorizationService } from './service'; - -import { Feature } from '../../../../../../plugins/features/server'; -import { XPackMainPlugin } from '../../../../xpack_main/xpack_main'; -import { actionsFactory } from './actions'; -import { initAppAuthorization } from './app_authorization'; - -const actions = actionsFactory({ - get(key: string) { - if (key === 'pkg.version') { - return `1.0.0-zeta1`; - } - - throw new Error(`Unexpected config key: ${key}`); - }, -}); - -const createMockXPackMainPlugin = (): XPackMainPlugin => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo', - app: ['foo'], - privileges: {}, - }, - ]; - return { - getFeatures: () => features, - } as XPackMainPlugin; -}; - -describe('initAppAuthorization', () => { - test(`route that doesn't start with "/app/" continues`, async () => { - const server = new Server(); - initAppAuthorization(server, createMockXPackMainPlugin(), {} as AuthorizationService); - server.route([ - { - method: 'GET', - path: '/api/foo', - handler: () => { - return 'foo app response'; - }, - }, - ]); - const { result, statusCode } = await server.inject({ - method: 'GET', - url: '/api/foo', - }); - expect(result).toBe('foo app response'); - expect(statusCode).toBe(200); - }); - - test(`protected route that starts with "/app/", but "mode.useRbacForRequest()" returns false continues`, async () => { - const server = new Server(); - const mockAuthorizationService: AuthorizationService = { - mode: { - useRbacForRequest: jest.fn().mockReturnValue(false), - }, - } as any; - initAppAuthorization(server, createMockXPackMainPlugin(), mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/app/foo', - handler: () => { - return 'foo app response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/app/foo', - }); - expect(result).toBe('foo app response'); - expect(statusCode).toBe(200); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); - }); - - test(`unprotected route that starts with "/app/", and "mode.useRbacForRequest()" returns true continues`, async () => { - const server = new Server(); - const mockAuthorizationService: AuthorizationService = { - actions, - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAppAuthorization(server, createMockXPackMainPlugin(), mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/app/bar', - handler: () => { - return 'bar app response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/app/bar', - }); - expect(result).toBe('bar app response'); - expect(statusCode).toBe(200); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); - }); - - test(`protected route that starts with "/app/", "mode.useRbacForRequest()" returns true and user is authorized continues`, async () => { - const headers = { - authorization: 'foo', - }; - const server = new Server(); - const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: true }); - const mockAuthorizationService: AuthorizationService = { - actions, - checkPrivilegesDynamicallyWithRequest: (req: any) => { - // hapi conceals the actual "request" from us, so we make sure that the headers are passed to - // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with - expect(req.headers).toMatchObject(headers); - - return mockCheckPrivileges; - }, - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAppAuthorization(server, createMockXPackMainPlugin(), mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/app/foo', - handler: () => { - return 'foo app response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/app/foo', - headers, - }); - expect(result).toBe('foo app response'); - expect(statusCode).toBe(200); - expect(mockCheckPrivileges).toHaveBeenCalledWith(actions.app.get('foo')); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); - }); - - test(`protected route that starts with "/app/", "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 404`, async () => { - const headers = { - authorization: 'foo', - }; - const server = new Server(); - const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: false }); - const mockAuthorizationService: AuthorizationService = { - actions, - checkPrivilegesDynamicallyWithRequest: (req: any) => { - // hapi conceals the actual "request" from us, so we make sure that the headers are passed to - // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with - expect(req.headers).toMatchObject(headers); - - return mockCheckPrivileges; - }, - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAppAuthorization(server, createMockXPackMainPlugin(), mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/app/foo', - handler: () => { - return 'foo app response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/app/foo', - headers, - }); - expect(result).toMatchInlineSnapshot(` -Object { - "error": "Not Found", - "message": "Not Found", - "statusCode": 404, -} -`); - expect(statusCode).toBe(404); - expect(mockCheckPrivileges).toHaveBeenCalledWith(actions.app.get('foo')); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/index.ts b/x-pack/legacy/plugins/security/server/lib/authorization/index.ts deleted file mode 100644 index 32c05dc8a5ebc..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authorization/index.ts +++ /dev/null @@ -1,16 +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 { Actions } from './actions'; -export { createAuthorizationService } from './service'; -export { disableUICapabilitesFactory } from './disable_ui_capabilities'; -export { initAPIAuthorization } from './api_authorization'; -export { initAppAuthorization } from './app_authorization'; -export { PrivilegeSerializer } from './privilege_serializer'; -// @ts-ignore -export { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; -export { ResourceSerializer } from './resource_serializer'; -export { validateFeaturePrivileges } from './validate_feature_privileges'; diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/mode.test.ts b/x-pack/legacy/plugins/security/server/lib/authorization/mode.test.ts deleted file mode 100644 index 26a10295cc127..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authorization/mode.test.ts +++ /dev/null @@ -1,74 +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 { requestFixture } from '../__tests__/__fixtures__/request'; -import { authorizationModeFactory } from './mode'; - -class MockXPackInfoFeature { - public getLicenseCheckResults = jest.fn(); - - constructor(allowRbac: boolean) { - this.getLicenseCheckResults.mockReturnValue({ allowRbac }); - } -} - -describe(`#useRbacForRequest`, () => { - test(`throws an Error if request isn't specified`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(false); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); - - expect(() => mode.useRbacForRequest(undefined as any)).toThrowErrorMatchingInlineSnapshot( - `"Invalid value used as weak map key"` - ); - }); - - test(`throws an Error if request is "null"`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(false); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); - - expect(() => mode.useRbacForRequest(null as any)).toThrowErrorMatchingInlineSnapshot( - `"Invalid value used as weak map key"` - ); - }); - - test(`returns false if xpackInfoFeature.getLicenseCheckResults().allowRbac is false`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(false); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); - const request = requestFixture(); - - const result = mode.useRbacForRequest(request); - expect(result).toBe(false); - }); - - test(`returns false if xpackInfoFeature.getLicenseCheckResults().allowRbac is initially false, and changes to true`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(false); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); - const request = requestFixture(); - - expect(mode.useRbacForRequest(request)).toBe(false); - mockXpackInfoFeature.getLicenseCheckResults.mockReturnValue({ allowRbac: true }); - expect(mode.useRbacForRequest(request)).toBe(false); - }); - - test(`returns true if xpackInfoFeature.getLicenseCheckResults().allowRbac is true`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(true); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); - const request = requestFixture(); - - const result = mode.useRbacForRequest(request); - expect(result).toBe(true); - }); - - test(`returns true if xpackInfoFeature.getLicenseCheckResults().allowRbac is initially true, and changes to false`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(true); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); - const request = requestFixture(); - - expect(mode.useRbacForRequest(request)).toBe(true); - mockXpackInfoFeature.getLicenseCheckResults.mockReturnValue({ allowRbac: false }); - expect(mode.useRbacForRequest(request)).toBe(true); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/mode.ts b/x-pack/legacy/plugins/security/server/lib/authorization/mode.ts deleted file mode 100644 index ea4d811417130..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authorization/mode.ts +++ /dev/null @@ -1,34 +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 { Request } from 'hapi'; -import { XPackFeature } from '../../../../xpack_main/xpack_main'; - -export interface AuthorizationMode { - useRbacForRequest(request: Request): boolean; -} - -/* - * 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 function authorizationModeFactory(securityXPackFeature: XPackFeature) { - const useRbacForRequestCache = new WeakMap(); - - return { - useRbacForRequest(request: Request) { - if (!useRbacForRequestCache.has(request)) { - useRbacForRequestCache.set( - request, - Boolean(securityXPackFeature.getLicenseCheckResults().allowRbac) - ); - } - - return useRbacForRequestCache.get(request); - }, - }; -} diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/register_privileges_with_cluster.js b/x-pack/legacy/plugins/security/server/lib/authorization/register_privileges_with_cluster.js deleted file mode 100644 index 0150913d1b62b..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authorization/register_privileges_with_cluster.js +++ /dev/null @@ -1,73 +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 { difference, isEmpty, isEqual } from 'lodash'; -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { serializePrivileges } from './privileges_serializer'; - -export async function registerPrivilegesWithCluster(server) { - - const { application, privileges } = server.plugins.security.authorization; - - const arePrivilegesEqual = (existingPrivileges, expectedPrivileges) => { - // when comparing privileges, the order of the actions doesn't matter, lodash's isEqual - // doesn't know how to compare Sets - return isEqual(existingPrivileges, expectedPrivileges, (value, other, key) => { - if (key === 'actions' && Array.isArray(value) && Array.isArray(other)) { - // Array.sort() is in-place, and we don't want to be modifying the actual order - // of the arrays permanently, and there's potential they're frozen, so we're copying - // before comparing. - return isEqual([...value].sort(), [...other].sort()); - } - }); - }; - - const getPrivilegesToDelete = (existingPrivileges, expectedPrivileges) => { - if (isEmpty(existingPrivileges)) { - return []; - } - - return difference(Object.keys(existingPrivileges[application]), Object.keys(expectedPrivileges[application])); - }; - - const expectedPrivileges = serializePrivileges(application, privileges.get()); - - server.log(['security', 'debug'], `Registering Kibana Privileges with Elasticsearch for ${application}`); - - const callCluster = getClient(server).callWithInternalUser; - - try { - // we only want to post the privileges when they're going to change as Elasticsearch has - // to clear the role cache to get these changes reflected in the _has_privileges API - const existingPrivileges = await callCluster(`shield.getPrivilege`, { privilege: application }); - if (arePrivilegesEqual(existingPrivileges, expectedPrivileges)) { - server.log(['security', 'debug'], `Kibana Privileges already registered with Elasticearch for ${application}`); - return; - } - - const privilegesToDelete = getPrivilegesToDelete(existingPrivileges, expectedPrivileges); - for (const privilegeToDelete of privilegesToDelete) { - server.log(['security', 'debug'], `Deleting Kibana Privilege ${privilegeToDelete} from Elasticearch for ${application}`); - try { - await callCluster('shield.deletePrivilege', { - application, - privilege: privilegeToDelete - }); - } catch (err) { - server.log(['security', 'error'], `Error deleting Kibana Privilege ${privilegeToDelete}`); - throw err; - } - } - - await callCluster('shield.postPrivileges', { - body: expectedPrivileges - }); - server.log(['security', 'debug'], `Updated Kibana Privileges with Elasticearch for ${application}`); - } catch (err) { - server.log(['security', 'error'], `Error registering Kibana Privileges with Elasticsearch for ${application}: ${err.message}`); - throw err; - } -} diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/service.test.ts b/x-pack/legacy/plugins/security/server/lib/authorization/service.test.ts deleted file mode 100644 index a4c733a7e9717..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authorization/service.test.ts +++ /dev/null @@ -1,112 +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 { - mockActionsFactory, - mockAuthorizationModeFactory, - mockCheckPrivilegesDynamicallyWithRequestFactory, - mockCheckPrivilegesWithRequestFactory, - mockCheckSavedObjectsPrivilegesWithRequestFactory, - mockGetClient, - mockPrivilegesFactory, -} from './service.test.mocks'; - -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { actionsFactory } from './actions'; -import { checkPrivilegesWithRequestFactory } from './check_privileges'; -import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; -import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; -import { authorizationModeFactory } from './mode'; -import { privilegesFactory } from './privileges'; -import { createAuthorizationService } from './service'; - -const createMockConfig = (settings: Record = {}) => { - const mockConfig = { - get: jest.fn(), - }; - - mockConfig.get.mockImplementation((key: string) => settings[key]); - - return mockConfig; -}; - -test(`returns exposed services`, () => { - const kibanaIndex = '.a-kibana-index'; - const mockConfig = createMockConfig({ - 'kibana.index': kibanaIndex, - }); - const mockServer = { - expose: jest.fn(), - config: jest.fn().mockReturnValue(mockConfig), - plugins: Symbol(), - savedObjects: Symbol(), - log: Symbol(), - }; - const mockShieldClient = Symbol(); - mockGetClient.mockReturnValue(mockShieldClient); - - const mockCheckPrivilegesWithRequest = Symbol(); - mockCheckPrivilegesWithRequestFactory.mockReturnValue(mockCheckPrivilegesWithRequest); - - const mockCheckPrivilegesDynamicallyWithRequest = Symbol(); - mockCheckPrivilegesDynamicallyWithRequestFactory.mockReturnValue( - mockCheckPrivilegesDynamicallyWithRequest - ); - - const mockCheckSavedObjectsPrivilegesWithRequest = Symbol(); - mockCheckSavedObjectsPrivilegesWithRequestFactory.mockReturnValue( - mockCheckSavedObjectsPrivilegesWithRequest - ); - - const mockActions = Symbol(); - mockActionsFactory.mockReturnValue(mockActions); - const mockXpackInfoFeature = Symbol(); - const mockFeatures = Symbol(); - const mockXpackMainPlugin = { - getFeatures: () => mockFeatures, - }; - const mockPrivilegesService = Symbol(); - mockPrivilegesFactory.mockReturnValue(mockPrivilegesService); - const mockAuthorizationMode = Symbol(); - mockAuthorizationModeFactory.mockReturnValue(mockAuthorizationMode); - const mockSpaces = Symbol(); - - const authorization = createAuthorizationService( - mockServer as any, - mockXpackInfoFeature as any, - mockXpackMainPlugin as any, - mockSpaces as any - ); - - const application = `kibana-${kibanaIndex}`; - expect(getClient).toHaveBeenCalledWith(mockServer); - expect(actionsFactory).toHaveBeenCalledWith(mockConfig); - expect(checkPrivilegesWithRequestFactory).toHaveBeenCalledWith( - mockActions, - application, - mockShieldClient - ); - expect(checkPrivilegesDynamicallyWithRequestFactory).toHaveBeenCalledWith( - mockCheckPrivilegesWithRequest, - mockSpaces - ); - expect(checkSavedObjectsPrivilegesWithRequestFactory).toHaveBeenCalledWith( - mockCheckPrivilegesWithRequest, - mockSpaces - ); - expect(privilegesFactory).toHaveBeenCalledWith(mockActions, mockXpackMainPlugin); - expect(authorizationModeFactory).toHaveBeenCalledWith(mockXpackInfoFeature); - - expect(authorization).toEqual({ - actions: mockActions, - application, - checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - mode: mockAuthorizationMode, - privileges: mockPrivilegesService, - }); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/service.ts b/x-pack/legacy/plugins/security/server/lib/authorization/service.ts deleted file mode 100644 index 3d248adb9f8b8..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/authorization/service.ts +++ /dev/null @@ -1,75 +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 { Server } from 'hapi'; - -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { LegacySpacesPlugin } from '../../../../spaces'; -import { XPackFeature, XPackMainPlugin } from '../../../../xpack_main/xpack_main'; -import { APPLICATION_PREFIX } from '../../../common/constants'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; -import { Actions, actionsFactory } from './actions'; -import { CheckPrivilegesWithRequest, checkPrivilegesWithRequestFactory } from './check_privileges'; -import { - CheckPrivilegesDynamicallyWithRequest, - checkPrivilegesDynamicallyWithRequestFactory, -} from './check_privileges_dynamically'; -import { AuthorizationMode, authorizationModeFactory } from './mode'; -import { privilegesFactory, PrivilegesService } from './privileges'; -import { - CheckSavedObjectsPrivilegesWithRequest, - checkSavedObjectsPrivilegesWithRequestFactory, -} from './check_saved_objects_privileges'; - -export interface AuthorizationService { - actions: Actions; - application: string; - checkPrivilegesWithRequest: CheckPrivilegesWithRequest; - checkPrivilegesDynamicallyWithRequest: CheckPrivilegesDynamicallyWithRequest; - checkSavedObjectsPrivilegesWithRequest: CheckSavedObjectsPrivilegesWithRequest; - mode: AuthorizationMode; - privileges: PrivilegesService; -} - -export function createAuthorizationService( - server: Server, - securityXPackFeature: XPackFeature, - xpackMainPlugin: XPackMainPlugin, - spaces: OptionalPlugin -): AuthorizationService { - const shieldClient = getClient(server); - const config = server.config(); - - const actions = actionsFactory(config); - const application = `${APPLICATION_PREFIX}${config.get('kibana.index')}`; - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( - actions, - application, - shieldClient - ); - const checkPrivilegesDynamicallyWithRequest = checkPrivilegesDynamicallyWithRequestFactory( - checkPrivilegesWithRequest, - spaces - ); - - const checkSavedObjectsPrivilegesWithRequest = checkSavedObjectsPrivilegesWithRequestFactory( - checkPrivilegesWithRequest, - spaces - ); - - const mode = authorizationModeFactory(securityXPackFeature); - const privileges = privilegesFactory(actions, xpackMainPlugin); - - return { - actions, - application, - checkPrivilegesWithRequest, - checkPrivilegesDynamicallyWithRequest, - checkSavedObjectsPrivilegesWithRequest, - mode, - privileges, - }; -} diff --git a/x-pack/legacy/plugins/security/server/lib/check_license.js b/x-pack/legacy/plugins/security/server/lib/check_license.js deleted file mode 100644 index 2a6650e9e2b0e..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/check_license.js +++ /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. - */ - -/** - * @typedef {Object} LicenseCheckResult Result of the license check. - * @property {boolean} showLogin Indicates whether we show login page or skip it. - * @property {boolean} allowLogin Indicates whether we allow login or disable it on the login page. - * @property {boolean} showLinks Indicates whether we show security links throughout the kibana app. - * @property {boolean} allowRoleDocumentLevelSecurity Indicates whether we allow users to define document level - * security in roles. - * @property {boolean} allowRoleFieldLevelSecurity Indicates whether we allow users to define field level security - * in roles - * @property {string} [linksMessage] Message to show when security links are clicked throughout the kibana app. - */ - -/** - * Returns object that defines behavior of the security related areas (login page, user management etc.) based - * on the license information extracted from the xPackInfo. - * @param {XPackInfo} xPackInfo XPackInfo instance to extract license information from. - * @returns {LicenseCheckResult} - */ -export function checkLicense(xPackInfo) { - // If, for some reason, we cannot get license information from Elasticsearch, - // assume worst-case and lock user at login screen. - if (!xPackInfo.isAvailable()) { - return { - showLogin: true, - allowLogin: false, - showLinks: false, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - allowRbac: false, - layout: xPackInfo.isXpackUnavailable() ? 'error-xpack-unavailable' : 'error-es-unavailable' - }; - } - - const isEnabledInES = xPackInfo.feature('security').isEnabled(); - if (!isEnabledInES) { - return { - showLogin: false, - allowLogin: false, - showLinks: false, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - allowRbac: false, - linksMessage: 'Access is denied because Security is disabled in Elasticsearch.' - }; - } - - const isLicensePlatinumOrTrial = xPackInfo.license.isOneOf(['platinum', 'trial']); - return { - showLogin: true, - allowLogin: true, - showLinks: true, - // Only platinum and trial licenses are compliant with field- and document-level security. - allowRoleDocumentLevelSecurity: isLicensePlatinumOrTrial, - allowRoleFieldLevelSecurity: isLicensePlatinumOrTrial, - allowRbac: true, - }; -} diff --git a/x-pack/legacy/plugins/security/server/lib/deep_freeze.js b/x-pack/legacy/plugins/security/server/lib/deep_freeze.js deleted file mode 100644 index 0f9363cb410f6..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/deep_freeze.js +++ /dev/null @@ -1,20 +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 { isObject } from 'lodash'; - -export function deepFreeze(object) { - // for any properties that reference an object, makes sure that object is - // recursively frozen as well - Object.keys(object).forEach(key => { - const value = object[key]; - if (isObject(value)) { - deepFreeze(value); - } - }); - - return Object.freeze(object); -} diff --git a/x-pack/legacy/plugins/security/server/lib/deep_freeze.test.js b/x-pack/legacy/plugins/security/server/lib/deep_freeze.test.js deleted file mode 100644 index dd227fa6269bf..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/deep_freeze.test.js +++ /dev/null @@ -1,97 +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 { deepFreeze } from './deep_freeze'; - -test(`freezes result and input`, () => { - const input = {}; - const result = deepFreeze(input); - - Object.isFrozen(input); - Object.isFrozen(result); -}); - -test(`freezes top-level properties that are objects`, () => { - const result = deepFreeze({ - object: {}, - array: [], - fn: () => {}, - number: 1, - string: '', - }); - - Object.isFrozen(result.object); - Object.isFrozen(result.array); - Object.isFrozen(result.fn); - Object.isFrozen(result.number); - Object.isFrozen(result.string); -}); - -test(`freezes child properties that are objects`, () => { - const result = deepFreeze({ - object: { - object: { - }, - array: [], - fn: () => {}, - number: 1, - string: '', - }, - array: [ - {}, - [], - () => {}, - 1, - '', - ], - }); - - Object.isFrozen(result.object.object); - Object.isFrozen(result.object.array); - Object.isFrozen(result.object.fn); - Object.isFrozen(result.object.number); - Object.isFrozen(result.object.string); - Object.isFrozen(result.array[0]); - Object.isFrozen(result.array[1]); - Object.isFrozen(result.array[2]); - Object.isFrozen(result.array[3]); - Object.isFrozen(result.array[4]); -}); - -test(`freezes grand-child properties that are objects`, () => { - const result = deepFreeze({ - object: { - object: { - object: { - }, - array: [], - fn: () => {}, - number: 1, - string: '', - }, - }, - array: [ - [ - {}, - [], - () => {}, - 1, - '', - ], - ], - }); - - Object.isFrozen(result.object.object.object); - Object.isFrozen(result.object.object.array); - Object.isFrozen(result.object.object.fn); - Object.isFrozen(result.object.object.number); - Object.isFrozen(result.object.object.string); - Object.isFrozen(result.array[0][0]); - Object.isFrozen(result.array[0][1]); - Object.isFrozen(result.array[0][2]); - Object.isFrozen(result.array[0][3]); - Object.isFrozen(result.array[0][4]); -}); diff --git a/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js b/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js index 41db792b33d94..64816bf4d23d7 100644 --- a/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js +++ b/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js @@ -7,10 +7,8 @@ const Boom = require('boom'); export function routePreCheckLicense(server) { - const xpackMainPlugin = server.plugins.xpack_main; - const pluginId = 'security'; return function forbidApiAccess() { - const licenseCheckResults = xpackMainPlugin.info.feature(pluginId).getLicenseCheckResults(); + const licenseCheckResults = server.newPlatform.setup.plugins.security.__legacyCompat.license.getFeatures(); if (!licenseCheckResults.showLinks) { throw Boom.forbidden(licenseCheckResults.linksMessage); } else { diff --git a/x-pack/legacy/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.js b/x-pack/legacy/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.js deleted file mode 100644 index d45e42e430a0b..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.js +++ /dev/null @@ -1,154 +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 { get, uniq } from 'lodash'; - -export class SecureSavedObjectsClientWrapper { - constructor(options) { - const { - actions, - auditLogger, - baseClient, - checkSavedObjectsPrivilegesWithRequest, - errors, - request, - savedObjectTypes, - } = options; - - this.errors = errors; - this._actions = actions; - this._auditLogger = auditLogger; - this._baseClient = baseClient; - this._checkSavedObjectsPrivileges = checkSavedObjectsPrivilegesWithRequest(request); - this._savedObjectTypes = savedObjectTypes; - } - - async create(type, attributes = {}, options = {}) { - await this._ensureAuthorized( - type, - 'create', - options.namespace, - { type, attributes, options }, - ); - - return await this._baseClient.create(type, attributes, options); - } - - async bulkCreate(objects, options = {}) { - const types = uniq(objects.map(o => o.type)); - await this._ensureAuthorized( - types, - 'bulk_create', - options.namespace, - { objects, options }, - ); - - return await this._baseClient.bulkCreate(objects, options); - } - - async delete(type, id, options = {}) { - await this._ensureAuthorized( - type, - 'delete', - options.namespace, - { type, id, options }, - ); - - return await this._baseClient.delete(type, id, options); - } - - async find(options = {}) { - await this._ensureAuthorized( - options.type, - 'find', - options.namespace, - { options } - ); - - return this._baseClient.find(options); - } - - async bulkGet(objects = [], options = {}) { - const types = uniq(objects.map(o => o.type)); - await this._ensureAuthorized( - types, - 'bulk_get', - options.namespace, - { objects, options }, - ); - - return await this._baseClient.bulkGet(objects, options); - } - - async get(type, id, options = {}) { - await this._ensureAuthorized( - type, - 'get', - options.namespace, - { type, id, options }, - ); - - return await this._baseClient.get(type, id, options); - } - - async update(type, id, attributes, options = {}) { - await this._ensureAuthorized( - type, - 'update', - options.namespace, - { type, id, attributes, options }, - ); - - return await this._baseClient.update(type, id, attributes, options); - } - - async bulkUpdate(objects = [], options) { - const types = uniq(objects.map(o => o.type)); - await this._ensureAuthorized( - types, - 'bulk_update', - options && options.namespace, - { objects, options }, - ); - - return await this._baseClient.bulkUpdate(objects, options); - } - - async _checkPrivileges(actions, namespace) { - try { - return await this._checkSavedObjectsPrivileges(actions, namespace); - } catch (error) { - const { reason } = get(error, 'body.error', {}); - throw this.errors.decorateGeneralError(error, reason); - } - } - - async _ensureAuthorized(typeOrTypes, action, namespace, args) { - const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; - const actionsToTypesMap = new Map(types.map(type => [this._actions.savedObject.get(type, action), type])); - const actions = Array.from(actionsToTypesMap.keys()); - const { hasAllRequested, username, privileges } = await this._checkPrivileges(actions, namespace); - - if (hasAllRequested) { - this._auditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); - } else { - const missingPrivileges = this._getMissingPrivileges(privileges); - this._auditLogger.savedObjectsAuthorizationFailure( - username, - action, - types, - missingPrivileges, - args - ); - const msg = `Unable to ${action} ${missingPrivileges.map(privilege => actionsToTypesMap.get(privilege)).sort().join(',')}`; - throw this.errors.decorateForbiddenError(new Error(msg)); - } - } - - _getMissingPrivileges(response) { - return Object.keys(response).filter(privilege => !response[privilege]); - } -} diff --git a/x-pack/legacy/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.test.js b/x-pack/legacy/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.test.js deleted file mode 100644 index 8bc1aa0fbe2f8..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.test.js +++ /dev/null @@ -1,1139 +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 { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; - -const createMockErrors = () => { - const forbiddenError = new Error('Mock ForbiddenError'); - const generalError = new Error('Mock GeneralError'); - - return { - forbiddenError, - decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), - generalError, - decorateGeneralError: jest.fn().mockReturnValue(generalError) - }; -}; - -const createMockAuditLogger = () => { - return { - savedObjectsAuthorizationFailure: jest.fn(), - savedObjectsAuthorizationSuccess: jest.fn(), - }; -}; - -const createMockActions = () => { - return { - savedObject: { - get(type, action) { - return `mock-saved_object:${type}/${action}`; - } - } - }; -}; - -describe('#errors', () => { - test(`assigns errors from constructor to .errors`, () => { - const errors = Symbol(); - - const client = new SecureSavedObjectsClientWrapper({ - checkSavedObjectsPrivilegesWithRequest: () => {}, - errors - }); - - expect(client.errors).toBe(errors); - }); -}); - -describe(`spaces disabled`, () => { - describe('#create', () => { - test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckSavedObjectsPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckSavedObjectsPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckSavedObjectsPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'create')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'create')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const attributes = Symbol(); - const options = Object.freeze({ namespace: Symbol() }); - - await expect(client.create(type, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'create')], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'create', - [type], - [mockActions.savedObject.get(type, 'create')], - { - type, - attributes, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.create when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - create: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'create')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const attributes = Symbol(); - const options = Object.freeze({ namespace: Symbol() }); - - const result = await client.create(type, attributes, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'create')], options.namespace); - expect(mockBaseClient.create).toHaveBeenCalledWith(type, attributes, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'create', [type], { - type, - attributes, - options, - }); - }); - }); - - describe('#bulkCreate', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - const options = Object.freeze({ namespace: Symbol() }); - - await expect(client.bulkCreate([{ type }], options)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_create')], options.namespace); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type1, 'bulk_create')]: false, - [mockActions.savedObject.get(type2, 'bulk_create')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const objects = [ - { type: type1 }, - { type: type1 }, - { type: type2 }, - ]; - const options = Object.freeze({ namespace: Symbol() }); - - await expect(client.bulkCreate(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_create'), - mockActions.savedObject.get(type2, 'bulk_create'), - ], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_create', - [type1, type2], - [mockActions.savedObject.get(type1, 'bulk_create')], - { - objects, - options, - } - ); - }); - - test(`returns result of baseClient.bulkCreate when authorized`, async () => { - const username = Symbol(); - const type1 = 'foo'; - const type2 = 'bar'; - const returnValue = Symbol(); - const mockBaseClient = { - bulkCreate: jest.fn().mockReturnValue(returnValue) - }; - const mockActions = createMockActions(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type1, 'bulk_create')]: true, - [mockActions.savedObject.get(type2, 'bulk_create')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const objects = [ - { type: type1, otherThing: 'sup' }, - { type: type2, otherThing: 'everyone' }, - ]; - const options = Object.freeze({ namespace: Symbol() }); - - const result = await client.bulkCreate(objects, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_create'), - mockActions.savedObject.get(type2, 'bulk_create'), - ], options.namespace); - expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith(objects, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_create', [type1, type2], { - objects, - options, - }); - }); - }); - - describe('#delete', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'delete')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'delete')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - - const options = Object.freeze({ namespace: Symbol() }); - - await expect(client.delete(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'delete')], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'delete', - [type], - [mockActions.savedObject.get(type, 'delete')], - { - type, - id, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of internalRepository.delete when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - delete: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'delete')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const options = Object.freeze({ namespace: Symbol() }); - - const result = await client.delete(type, id, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'delete')], options.namespace); - expect(mockBaseClient.delete).toHaveBeenCalledWith(type, id, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete', [type], { - type, - id, - options, - }); - }); - }); - - describe('#find', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'find')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'find')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const options = Object.freeze({ type, namespace: Symbol }); - - await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'find')], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type], - [mockActions.savedObject.get(type, 'find')], - { - options - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type1, 'find')]: false, - [mockActions.savedObject.get(type2, 'find')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const options = Object.freeze({ type: [type1, type2], namespace: Symbol() }); - - await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'find'), - mockActions.savedObject.get(type2, 'find') - ], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type1, type2], - [mockActions.savedObject.get(type1, 'find')], - { - options - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.find when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - find: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'find')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const options = Object.freeze({ type, namespace: Symbol }); - - const result = await client.find(options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'find')], options.namespace); - expect(mockBaseClient.find).toHaveBeenCalledWith(options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'find', [type], { - options, - }); - }); - }); - - describe('#bulkGet', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_get')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type1, 'bulk_get')]: false, - [mockActions.savedObject.get(type2, 'bulk_get')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const objects = [ - { type: type1 }, - { type: type1 }, - { type: type2 }, - ]; - const options = Object.freeze({ namespace: Symbol }); - - await expect(client.bulkGet(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_get'), - mockActions.savedObject.get(type2, 'bulk_get'), - ], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_get', - [type1, type2], - [mockActions.savedObject.get(type1, 'bulk_get')], - { - objects, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.bulkGet when authorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - bulkGet: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type1, 'bulk_get')]: true, - [mockActions.savedObject.get(type2, 'bulk_get')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const objects = [ - { type: type1, id: 'foo-id' }, - { type: type2, id: 'bar-id' }, - ]; - const options = Object.freeze({ namespace: Symbol }); - - const result = await client.bulkGet(objects, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_get'), - mockActions.savedObject.get(type2, 'bulk_get'), - ], options.namespace); - expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(objects, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { - objects, - options, - }); - }); - }); - - describe('#get', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'get')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'get')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const options = Object.freeze({ namespace: Symbol }); - - await expect(client.get(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'get')], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'get', - [type], - [mockActions.savedObject.get(type, 'get')], - { - type, - id, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.get when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - get: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'get')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const options = Object.freeze({ namespace: Symbol }); - - const result = await client.get(type, id, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'get')], options.namespace); - expect(mockBaseClient.get).toHaveBeenCalledWith(type, id, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { - type, - id, - options - }); - }); - }); - - describe('#update', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'update')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'update')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const attributes = Symbol(); - const options = Object.freeze({ namespace: Symbol }); - - await expect(client.update(type, id, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'update')], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'update', - [type], - [mockActions.savedObject.get(type, 'update')], - { - type, - id, - attributes, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.update when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - update: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'update')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const attributes = Symbol(); - const options = Object.freeze({ namespace: Symbol }); - - const result = await client.update(type, id, attributes, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'update')], options.namespace); - expect(mockBaseClient.update).toHaveBeenCalledWith(type, id, attributes, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'update', [type], { - type, - id, - attributes, - options, - }); - }); - }); - - describe('#bulkUpdate', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - const objects = [{ - type - }]; - await expect( - client.bulkUpdate(objects) - ).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_update')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'bulk_update')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const attributes = Symbol(); - const namespace = Symbol(); - - await expect( - client.bulkUpdate([{ type, id, attributes }], { namespace }) - ).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_update')], namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_update', - [type], - [mockActions.savedObject.get(type, 'bulk_update')], - { - objects: [ - { - type, - id, - attributes, - } - ], - options: { namespace } - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.bulkUpdate when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - bulkUpdate: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'bulkUpdate')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const attributes = Symbol(); - const namespace = Symbol(); - - const result = await client.bulkUpdate([{ type, id, attributes }], { namespace }); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_update')], namespace); - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith([{ type, id, attributes }], { namespace }); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_update', [type], { - objects: [{ - type, - id, - attributes, - }], - options: { namespace } - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/privileges/get.test.ts b/x-pack/legacy/plugins/security/server/routes/api/external/privileges/get.test.ts deleted file mode 100644 index 16a1b0f7e35a5..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/external/privileges/get.test.ts +++ /dev/null @@ -1,129 +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 Boom from 'boom'; -import { Server } from 'hapi'; -import { RawKibanaPrivileges } from '../../../../../common/model'; -import { initGetPrivilegesApi } from './get'; -import { AuthorizationService } from '../../../../lib/authorization/service'; - -const createRawKibanaPrivileges: () => RawKibanaPrivileges = () => { - return { - features: { - feature1: { - all: ['action1'], - }, - feature2: { - all: ['action2'], - }, - }, - space: { - all: ['space*'], - read: ['space:read'], - }, - global: { - all: ['*'], - read: ['something:/read'], - }, - reserved: { - customApplication1: ['custom-action1'], - customApplication2: ['custom-action2'], - }, - }; -}; - -const createMockServer = () => { - const mockServer = new Server({ debug: false, port: 8080 }); - - mockServer.plugins.security = { - authorization: ({ - privileges: { - get: jest.fn().mockImplementation(() => { - return createRawKibanaPrivileges(); - }), - }, - } as unknown) as AuthorizationService, - } as any; - return mockServer; -}; - -interface TestOptions { - preCheckLicenseImpl?: () => void; - includeActions?: boolean; - asserts: { - statusCode: number; - result: Record; - }; -} - -describe('GET privileges', () => { - const getPrivilegesTest = ( - description: string, - { preCheckLicenseImpl = () => null, includeActions, asserts }: TestOptions - ) => { - test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); - - initGetPrivilegesApi(mockServer, pre); - const headers = { - authorization: 'foo', - }; - - const url = `/api/security/privileges${includeActions ? '?includeActions=true' : ''}`; - - const request = { - method: 'GET', - url, - headers, - }; - const { result, statusCode } = await mockServer.inject(request); - - expect(pre).toHaveBeenCalled(); - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); - }); - }; - - describe('failure', () => { - getPrivilegesTest(`returns result of routePreCheckLicense`, { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, - }); - }); - - describe('success', () => { - getPrivilegesTest(`returns registered application privileges with actions when requested`, { - includeActions: true, - asserts: { - statusCode: 200, - result: createRawKibanaPrivileges(), - }, - }); - - getPrivilegesTest(`returns registered application privileges without actions`, { - includeActions: false, - asserts: { - statusCode: 200, - result: { - global: ['all', 'read'], - space: ['all', 'read'], - features: { - feature1: ['all'], - feature2: ['all'], - }, - reserved: ['customApplication1', 'customApplication2'], - }, - }, - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/privileges/get.ts b/x-pack/legacy/plugins/security/server/routes/api/external/privileges/get.ts deleted file mode 100644 index 273af1b3f0eb9..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/external/privileges/get.ts +++ /dev/null @@ -1,48 +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 Joi from 'joi'; -import { RawKibanaPrivileges } from '../../../../../common/model'; - -export function initGetPrivilegesApi( - server: Record, - routePreCheckLicenseFn: () => void -) { - server.route({ - method: 'GET', - path: '/api/security/privileges', - handler(req: Record) { - const { authorization } = server.plugins.security; - const privileges: RawKibanaPrivileges = authorization.privileges.get(); - - if (req.query.includeActions) { - return privileges; - } - - return { - global: Object.keys(privileges.global), - space: Object.keys(privileges.space), - features: Object.entries(privileges.features).reduce( - (acc, [featureId, featurePrivileges]) => { - return { - ...acc, - [featureId]: Object.keys(featurePrivileges), - }; - }, - {} - ), - reserved: Object.keys(privileges.reserved), - }; - }, - config: { - pre: [routePreCheckLicenseFn], - validate: { - query: Joi.object().keys({ - includeActions: Joi.bool(), - }), - }, - }, - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/privileges/index.ts b/x-pack/legacy/plugins/security/server/routes/api/external/privileges/index.ts deleted file mode 100644 index 2af1f99ef7f54..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/external/privileges/index.ts +++ /dev/null @@ -1,14 +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. - */ -// @ts-ignore -import { routePreCheckLicense } from '../../../../lib/route_pre_check_license'; -import { initGetPrivilegesApi } from './get'; - -export function initPrivilegesApi(server: Record) { - const routePreCheckLicenseFn = routePreCheckLicense(server); - - initGetPrivilegesApi(server, routePreCheckLicenseFn); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js deleted file mode 100644 index 8568321ba1941..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.js +++ /dev/null @@ -1,33 +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 Joi from 'joi'; -import { wrapError } from '../../../../../../../../plugins/security/server'; - -export function initDeleteRolesApi(server, callWithRequest, routePreCheckLicenseFn) { - server.route({ - method: 'DELETE', - path: '/api/security/role/{name}', - handler(request, h) { - const { name } = request.params; - return callWithRequest(request, 'shield.deleteRole', { name }).then( - () => h.response().code(204), - wrapError - ); - }, - config: { - validate: { - params: Joi.object() - .keys({ - name: Joi.string() - .required(), - }) - .required(), - }, - pre: [routePreCheckLicenseFn] - } - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.test.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.test.js deleted file mode 100644 index 638edf577da3a..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/delete.test.js +++ /dev/null @@ -1,124 +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 Hapi from 'hapi'; -import Boom from 'boom'; -import { initDeleteRolesApi } from './delete'; - -const createMockServer = () => { - const mockServer = new Hapi.Server({ debug: false, port: 8080 }); - return mockServer; -}; - -const defaultPreCheckLicenseImpl = () => null; - -describe('DELETE role', () => { - const deleteRoleTest = ( - description, - { - name, - preCheckLicenseImpl, - callWithRequestImpl, - asserts, - } - ) => { - test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - if (callWithRequestImpl) { - mockCallWithRequest.mockImplementation(callWithRequestImpl); - } - initDeleteRolesApi(mockServer, mockCallWithRequest, pre); - const headers = { - authorization: 'foo', - }; - - const request = { - method: 'DELETE', - url: `/api/security/role/${name}`, - headers, - }; - const { result, statusCode } = await mockServer.inject(request); - - if (preCheckLicenseImpl) { - expect(pre).toHaveBeenCalled(); - } else { - expect(pre).not.toHaveBeenCalled(); - } - - if (callWithRequestImpl) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), - 'shield.deleteRole', - { name }, - ); - } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); - } - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); - }); - }; - - describe('failure', () => { - deleteRoleTest(`requires name in params`, { - name: '', - asserts: { - statusCode: 404, - result: { - error: 'Not Found', - message: 'Not Found', - statusCode: 404, - }, - }, - }); - - deleteRoleTest(`returns result of routePreCheckLicense`, { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, - }); - - deleteRoleTest(`returns error from callWithRequest`, { - name: 'foo-role', - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpl: async () => { - throw Boom.notFound('test not found message'); - }, - asserts: { - statusCode: 404, - result: { - error: 'Not Found', - statusCode: 404, - message: 'test not found message', - }, - }, - }); - }); - - describe('success', () => { - deleteRoleTest(`deletes role`, { - name: 'foo-role', - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpl: async () => {}, - asserts: { - statusCode: 204, - result: null - } - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js deleted file mode 100644 index 3540d9b7a883b..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.js +++ /dev/null @@ -1,222 +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 _ from 'lodash'; -import Boom from 'boom'; -import { GLOBAL_RESOURCE, RESERVED_PRIVILEGES_APPLICATION_WILDCARD } from '../../../../../common/constants'; -import { wrapError } from '../../../../../../../../plugins/security/server'; -import { PrivilegeSerializer, ResourceSerializer } from '../../../../lib/authorization'; - -export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, application) { - - const transformKibanaApplicationsFromEs = (roleApplications) => { - const roleKibanaApplications = roleApplications - .filter( - roleApplication => roleApplication.application === application || - roleApplication.application === RESERVED_PRIVILEGES_APPLICATION_WILDCARD - ); - - // if any application entry contains an empty resource, we throw an error - if (roleKibanaApplications.some(entry => entry.resources.length === 0)) { - throw new Error(`ES returned an application entry without resources, can't process this`); - } - - // if there is an entry with the reserved privileges application wildcard - // and there are privileges which aren't reserved, we won't transform these - if (roleKibanaApplications.some(entry => - entry.application === RESERVED_PRIVILEGES_APPLICATION_WILDCARD && - !entry.privileges.every(privilege => PrivilegeSerializer.isSerializedReservedPrivilege(privilege))) - ) { - return { - success: false - }; - } - - // if space privilege assigned globally, we can't transform these - if (roleKibanaApplications.some(entry => - entry.resources.includes(GLOBAL_RESOURCE) && - entry.privileges.some(privilege => PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege))) - ) { - return { - success: false - }; - } - - // if global base or reserved privilege assigned at a space, we can't transform these - if (roleKibanaApplications.some(entry => - !entry.resources.includes(GLOBAL_RESOURCE) && - entry.privileges.some(privilege => - PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) || - PrivilegeSerializer.isSerializedReservedPrivilege(privilege) - )) - ) { - return { - success: false - }; - } - - // if reserved privilege assigned with feature or base privileges, we won't transform these - if (roleKibanaApplications.some(entry => - entry.privileges.some(privilege => PrivilegeSerializer.isSerializedReservedPrivilege(privilege)) && - entry.privileges.some(privilege => !PrivilegeSerializer.isSerializedReservedPrivilege(privilege))) - ) { - return { - success: false - }; - } - - // if base privilege assigned with feature privileges, we won't transform these - if (roleKibanaApplications.some(entry => - entry.privileges.some(privilege => PrivilegeSerializer.isSerializedFeaturePrivilege(privilege)) && - ( - entry.privileges.some(privilege => PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege)) || - entry.privileges.some(privilege => PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege)) - ) - )) { - return { - success: false - }; - } - - // if any application entry contains the '*' resource in addition to another resource, we can't transform these - if (roleKibanaApplications.some(entry => entry.resources.includes(GLOBAL_RESOURCE) && entry.resources.length > 1)) { - return { - success: false - }; - } - - const allResources = _.flatten(roleKibanaApplications.map(entry => entry.resources)); - // if we have improperly formatted resource entries, we can't transform these - if (allResources.some(resource => resource !== GLOBAL_RESOURCE && !ResourceSerializer.isSerializedSpaceResource(resource))) { - return { - success: false - }; - } - - // if we have resources duplicated in entries, we won't transform these - if (allResources.length !== _.uniq(allResources).length) { - return { - success: false - }; - } - - return { - success: true, - value: roleKibanaApplications.map(({ resources, privileges }) => { - // if we're dealing with a global entry, which we've ensured above is only possible if it's the only item in the array - if (resources.length === 1 && resources[0] === GLOBAL_RESOURCE) { - const reservedPrivileges = privileges.filter(privilege => PrivilegeSerializer.isSerializedReservedPrivilege(privilege)); - const basePrivileges = privileges.filter(privilege => PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege)); - const featurePrivileges = privileges.filter(privilege => PrivilegeSerializer.isSerializedFeaturePrivilege(privilege)); - - return { - ...reservedPrivileges.length ? { - _reserved: reservedPrivileges.map(privilege => PrivilegeSerializer.deserializeReservedPrivilege(privilege)) - } : {}, - base: basePrivileges.map(privilege => PrivilegeSerializer.serializeGlobalBasePrivilege(privilege)), - feature: featurePrivileges.reduce((acc, privilege) => { - const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege); - return { - ...acc, - [featurePrivilege.featureId]: _.uniq([ - ...acc[featurePrivilege.featureId] || [], - featurePrivilege.privilege - ]) - }; - }, {}), - spaces: ['*'] - }; - } - - const basePrivileges = privileges.filter(privilege => PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege)); - const featurePrivileges = privileges.filter(privilege => PrivilegeSerializer.isSerializedFeaturePrivilege(privilege)); - return { - base: basePrivileges.map(privilege => PrivilegeSerializer.deserializeSpaceBasePrivilege(privilege)), - feature: featurePrivileges.reduce((acc, privilege) => { - const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege); - return { - ...acc, - [featurePrivilege.featureId]: _.uniq([ - ...acc[featurePrivilege.featureId] || [], - featurePrivilege.privilege - ]) - }; - }, {}), - spaces: resources.map(resource => ResourceSerializer.deserializeSpaceResource(resource)) - }; - }) - }; - }; - - const transformUnrecognizedApplicationsFromEs = (roleApplications) => { - return _.uniq(roleApplications - .filter(roleApplication => - roleApplication.application !== application && - roleApplication.application !== RESERVED_PRIVILEGES_APPLICATION_WILDCARD - ) - .map(roleApplication => roleApplication.application)); - }; - - const transformRoleFromEs = (role, name) => { - const kibanaTransformResult = transformKibanaApplicationsFromEs(role.applications); - - return { - name, - metadata: role.metadata, - transient_metadata: role.transient_metadata, - elasticsearch: { - cluster: role.cluster, - indices: role.indices, - run_as: role.run_as, - }, - kibana: kibanaTransformResult.success ? kibanaTransformResult.value : [], - _transform_error: [ - ...(kibanaTransformResult.success ? [] : ['kibana']) - ], - _unrecognized_applications: transformUnrecognizedApplicationsFromEs(role.applications), - }; - }; - - const transformRolesFromEs = (roles) => { - return _.map(roles, (role, name) => transformRoleFromEs(role, name)); - }; - - server.route({ - method: 'GET', - path: '/api/security/role', - async handler(request) { - try { - const response = await callWithRequest(request, 'shield.getRole'); - return _.sortBy(transformRolesFromEs(response), 'name'); - } catch (error) { - return wrapError(error); - } - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); - - server.route({ - method: 'GET', - path: '/api/security/role/{name}', - async handler(request) { - const name = request.params.name; - try { - const response = await callWithRequest(request, 'shield.getRole', { name }); - if (response[name]) { - return transformRoleFromEs(response[name], name); - } - - return Boom.notFound(); - } catch (error) { - return wrapError(error); - } - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.test.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.test.js deleted file mode 100644 index 24aa4bd6e02b2..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/get.test.js +++ /dev/null @@ -1,2378 +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 Hapi from 'hapi'; -import Boom from 'boom'; -import { initGetRolesApi } from './get'; - -const application = 'kibana-.kibana'; -const reservedPrivilegesApplicationWildcard = 'kibana-*'; - -const createMockServer = () => { - const mockServer = new Hapi.Server({ debug: false, port: 8080 }); - return mockServer; -}; - -describe('GET roles', () => { - const getRolesTest = ( - description, - { - preCheckLicenseImpl = () => null, - callWithRequestImpl, - asserts, - } - ) => { - test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - if (callWithRequestImpl) { - mockCallWithRequest.mockImplementation(callWithRequestImpl); - } - initGetRolesApi(mockServer, mockCallWithRequest, pre, application); - const headers = { - authorization: 'foo', - }; - - const request = { - method: 'GET', - url: '/api/security/role', - headers, - }; - const { result, statusCode } = await mockServer.inject(request); - - expect(pre).toHaveBeenCalled(); - if (callWithRequestImpl) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), - 'shield.getRole' - ); - } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); - } - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); - }); - }; - - describe('failure', () => { - getRolesTest(`returns result of routePreCheckLicense`, { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, - }); - - getRolesTest(`returns error from callWithRequest`, { - callWithRequestImpl: async () => { - throw Boom.notAcceptable('test not acceptable message'); - }, - asserts: { - statusCode: 406, - result: { - error: 'Not Acceptable', - statusCode: 406, - message: 'test not acceptable message', - }, - }, - }); - }); - - describe('success', () => { - getRolesTest(`transforms elasticsearch privileges`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: ['manage_watcher'], - indices: [ - { - names: ['.kibana*'], - privileges: ['read', 'view_index_metadata'], - }, - ], - applications: [], - run_as: ['other_user'], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: ['manage_watcher'], - indices: [ - { - names: ['.kibana*'], - privileges: ['read', 'view_index_metadata'], - }, - ], - run_as: ['other_user'], - }, - kibana: [], - _transform_error: [], - _unrecognized_applications: [], - }, - ], - }, - }); - - describe('global', () => { - getRolesTest(`transforms matching applications with * resource to kibana global base privileges`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all', 'read'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: ['all', 'read'], - feature: {}, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`transforms matching applications with * resource to kibana global feature privileges`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['feature_foo.foo-privilege-1', 'feature_foo.foo-privilege-2', 'feature_bar.bar-privilege-1'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: [], - feature: { - foo: ['foo-privilege-1', 'foo-privilege-2'], - bar: ['bar-privilege-1'] - }, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`transforms matching applications with * resource to kibana _reserved privileges`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_customApplication1', 'reserved_customApplication2'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - _reserved: ['customApplication1', 'customApplication2'], - base: [], - feature: {}, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`transforms applications with wildcard and * resource to kibana _reserved privileges`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: reservedPrivilegesApplicationWildcard, - privileges: ['reserved_customApplication1', 'reserved_customApplication2'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - _reserved: ['customApplication1', 'customApplication2'], - base: [], - feature: {}, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - ], - }, - }); - }); - - describe('space', () => { - getRolesTest(`transforms matching applications with space resources to kibana space base privileges`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all', 'space_read'], - resources: ['space:marketing', 'space:sales'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: ['all', 'read'], - feature: {}, - spaces: ['marketing', 'sales'], - }, - { - base: ['read'], - feature: {}, - spaces: ['engineering'], - }, - ], - _transform_error: [], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`transforms matching applications with space resources to kibana space feature privileges`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['feature_foo.foo-privilege-1', 'feature_foo.foo-privilege-2', 'feature_bar.bar-privilege-1'], - resources: ['space:marketing', 'space:sales'], - }, - { - application, - privileges: ['feature_foo.foo-privilege-1'], - resources: ['space:engineering'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: [], - feature: { - foo: ['foo-privilege-1', 'foo-privilege-2'], - bar: ['bar-privilege-1'] - }, - spaces: ['marketing', 'sales'], - }, - { - base: [], - feature: { - foo: ['foo-privilege-1'], - }, - spaces: ['engineering'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - ], - }, - }); - }); - - getRolesTest(`return error if we have empty resources`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['read'], - resources: [], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 500, - result: { - error: 'Internal Server Error', - statusCode: 500, - message: 'An internal server error occurred', - }, - }, - }); - - getRolesTest(`resource not * without space: prefix returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['read'], - resources: ['default'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`* and a space in the same entry returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all'], - resources: ['*', 'space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`* appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all'], - resources: ['*'], - }, - { - application, - privileges: ['read'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`space appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all'], - resources: ['space:engineering'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`space privilege assigned globally returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all'], - resources: ['*'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`space privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: reservedPrivilegesApplicationWildcard, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`global base privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all'], - resources: ['space:marketing'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`global base privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: reservedPrivilegesApplicationWildcard, - privileges: ['all'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`reserved privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_foo'], - resources: ['space:marketing'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest( - `reserved privilege assigned with a base privilege returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_foo', 'read'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest( - `reserved privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_foo', 'feature_foo.foo-privilege-1'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest( - `global base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all', 'feature_foo.foo-privilege-1'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest( - `space base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all', 'feature_foo.foo-privilege-1'], - resources: ['space:space_1'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`transforms unrecognized applications`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: 'kibana-.another-kibana', - privileges: ['read'], - resources: ['*'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: [], - _unrecognized_applications: ['kibana-.another-kibana'] - }, - ], - }, - }); - - getRolesTest(`returns a sorted list of roles`, { - callWithRequestImpl: async () => ({ - z_role: { - cluster: [], - indices: [], - applications: [ - { - application: 'kibana-.another-kibana', - privileges: ['read'], - resources: ['*'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - a_role: { - cluster: [], - indices: [], - applications: [ - { - application: 'kibana-.another-kibana', - privileges: ['read'], - resources: ['*'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - b_role: { - cluster: [], - indices: [], - applications: [ - { - application: 'kibana-.another-kibana', - privileges: ['read'], - resources: ['*'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'a_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: [], - _unrecognized_applications: ['kibana-.another-kibana'] - }, - { - name: 'b_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: [], - _unrecognized_applications: ['kibana-.another-kibana'] - }, - { - name: 'z_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: [], - _unrecognized_applications: ['kibana-.another-kibana'] - }, - ], - }, - }); - }); -}); - -describe('GET role', () => { - const getRoleTest = ( - description, - { - name, - preCheckLicenseImpl = () => null, - callWithRequestImpl, - asserts, - } - ) => { - test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - if (callWithRequestImpl) { - mockCallWithRequest.mockImplementation(callWithRequestImpl); - } - initGetRolesApi(mockServer, mockCallWithRequest, pre, 'kibana-.kibana'); - const headers = { - authorization: 'foo', - }; - - const request = { - method: 'GET', - url: `/api/security/role/${name}`, - headers, - }; - const { result, statusCode } = await mockServer.inject(request); - - expect(pre).toHaveBeenCalled(); - if (callWithRequestImpl) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), - 'shield.getRole', - { name } - ); - } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); - } - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); - }); - }; - - describe('failure', () => { - getRoleTest(`returns result of routePreCheckLicense`, { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, - }); - - getRoleTest(`returns error from callWithRequest`, { - name: 'first_role', - callWithRequestImpl: async () => { - throw Boom.notAcceptable('test not acceptable message'); - }, - asserts: { - statusCode: 406, - result: { - error: 'Not Acceptable', - statusCode: 406, - message: 'test not acceptable message', - }, - }, - }); - - getRoleTest(`return error if we have empty resources`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['read'], - resources: [], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 500, - result: { - error: 'Internal Server Error', - statusCode: 500, - message: 'An internal server error occurred', - }, - }, - }); - }); - - describe('success', () => { - getRoleTest(`transforms elasticsearch privileges`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: ['manage_watcher'], - indices: [ - { - names: ['.kibana*'], - privileges: ['read', 'view_index_metadata'], - }, - ], - applications: [], - run_as: ['other_user'], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: ['manage_watcher'], - indices: [ - { - names: ['.kibana*'], - privileges: ['read', 'view_index_metadata'], - }, - ], - run_as: ['other_user'], - }, - kibana: [], - _transform_error: [], - _unrecognized_applications: [], - }, - }, - }); - - describe('global', () => { - getRoleTest(`transforms matching applications with * resource to kibana global base privileges`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all', 'read'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: ['all', 'read'], - feature: {}, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`transforms matching applications with * resource to kibana global feature privileges`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['feature_foo.foo-privilege-1', 'feature_foo.foo-privilege-2', 'feature_bar.bar-privilege-1'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: [], - feature: { - foo: ['foo-privilege-1', 'foo-privilege-2'], - bar: ['bar-privilege-1'] - }, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`transforms matching applications with * resource to kibana _reserved privileges`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_customApplication1', 'reserved_customApplication2'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - _reserved: ['customApplication1', 'customApplication2'], - base: [], - feature: {}, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`transforms applications with wildcard and * resource to kibana _reserved privileges`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: reservedPrivilegesApplicationWildcard, - privileges: ['reserved_customApplication1', 'reserved_customApplication2'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - _reserved: ['customApplication1', 'customApplication2'], - base: [], - feature: {}, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - }, - }); - }); - - describe('space', () => { - getRoleTest(`transforms matching applications with space resources to kibana space base privileges`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all', 'space_read'], - resources: ['space:marketing', 'space:sales'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: ['all', 'read'], - feature: {}, - spaces: ['marketing', 'sales'], - }, - { - base: ['read'], - feature: {}, - spaces: ['engineering'], - }, - ], - _transform_error: [], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`transforms matching applications with space resources to kibana space feature privileges`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['feature_foo.foo-privilege-1', 'feature_foo.foo-privilege-2', 'feature_bar.bar-privilege-1'], - resources: ['space:marketing', 'space:sales'], - }, - { - application, - privileges: ['feature_foo.foo-privilege-1'], - resources: ['space:engineering'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: [], - feature: { - foo: ['foo-privilege-1', 'foo-privilege-2'], - bar: ['bar-privilege-1'] - }, - spaces: ['marketing', 'sales'], - }, - { - base: [], - feature: { - foo: ['foo-privilege-1'], - }, - spaces: ['engineering'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - }, - }); - }); - - getRoleTest(`resource not * without space: prefix returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['read'], - resources: ['default'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - } - }); - - getRoleTest(`* and a space in the same entry returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['read'], - resources: ['default'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`* appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all'], - resources: ['space:engineering'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`space privilege assigned globally returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all'], - resources: ['*'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`space privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: reservedPrivilegesApplicationWildcard, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`global base privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all'], - resources: ['space:marketing'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`global base privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: reservedPrivilegesApplicationWildcard, - privileges: ['all'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - - getRoleTest(`reserved privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_foo'], - resources: ['space:marketing'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest( - `reserved privilege assigned with a base privilege returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_foo', 'read'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest( - `reserved privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_foo', 'feature_foo.foo-privilege-1'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest( - `global base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all', 'feature_foo.foo-privilege-1'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest( - `space base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all', 'feature_foo.foo-privilege-1'], - resources: ['space:space_1'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`transforms unrecognized applications`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: 'kibana-.another-kibana', - privileges: ['read'], - resources: ['*'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: [], - _unrecognized_applications: ['kibana-.another-kibana'] - }, - }, - }); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/index.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/index.js deleted file mode 100644 index e883e8a6a8631..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/index.js +++ /dev/null @@ -1,23 +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 { getClient } from '../../../../../../../server/lib/get_client_shield'; -import { routePreCheckLicense } from '../../../../lib/route_pre_check_license'; -import { initGetRolesApi } from './get'; -import { initDeleteRolesApi } from './delete'; -import { initPutRolesApi } from './put'; - -export function initExternalRolesApi(server) { - const callWithRequest = getClient(server).callWithRequest; - const routePreCheckLicenseFn = routePreCheckLicense(server); - - const { authorization } = server.plugins.security; - const { application } = authorization; - - initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, application); - initPutRolesApi(server, callWithRequest, routePreCheckLicenseFn, authorization, application); - initDeleteRolesApi(server, callWithRequest, routePreCheckLicenseFn); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js deleted file mode 100644 index 681d2220930ef..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.js +++ /dev/null @@ -1,164 +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 { flatten, pick, identity, intersection } from 'lodash'; -import Joi from 'joi'; -import { GLOBAL_RESOURCE } from '../../../../../common/constants'; -import { wrapError } from '../../../../../../../../plugins/security/server'; -import { PrivilegeSerializer, ResourceSerializer } from '../../../../lib/authorization'; - -export function initPutRolesApi( - server, - callWithRequest, - routePreCheckLicenseFn, - authorization, - application -) { - - const transformKibanaPrivilegesToEs = (kibanaPrivileges = []) => { - return kibanaPrivileges.map(({ base, feature, spaces }) => { - if (spaces.length === 1 && spaces[0] === GLOBAL_RESOURCE) { - return { - privileges: [ - ...base ? base.map( - privilege => PrivilegeSerializer.serializeGlobalBasePrivilege(privilege) - ) : [], - ...feature ? flatten( - Object.entries(feature).map( - ([featureName, featurePrivileges])=> featurePrivileges.map( - privilege => PrivilegeSerializer.serializeFeaturePrivilege(featureName, privilege) - ) - ) - ) : [] - ], - application, - resources: [GLOBAL_RESOURCE] - }; - } - - return { - privileges: [ - ...base ? base.map( - privilege => PrivilegeSerializer.serializeSpaceBasePrivilege(privilege) - ) : [], - ...feature ? flatten( - Object.entries(feature).map( - ([featureName, featurePrivileges])=> featurePrivileges.map( - privilege => PrivilegeSerializer.serializeFeaturePrivilege(featureName, privilege) - ) - ) - ) : [] - ], - application, - resources: spaces.map(resource => ResourceSerializer.serializeSpaceResource(resource)), - }; - }); - }; - - const transformRolesToEs = ( - payload, - existingApplications = [] - ) => { - const { elasticsearch = {}, kibana = [] } = payload; - const otherApplications = existingApplications.filter( - roleApplication => roleApplication.application !== application - ); - - return pick({ - metadata: payload.metadata, - cluster: elasticsearch.cluster || [], - indices: elasticsearch.indices || [], - run_as: elasticsearch.run_as || [], - applications: [ - ...transformKibanaPrivilegesToEs(kibana), - ...otherApplications, - ], - }, identity); - }; - - const getKibanaSchema = () => { - const privileges = authorization.privileges.get(); - const allSpacesSchema = Joi.array().length(1).items(Joi.string().valid([GLOBAL_RESOURCE])); - return Joi.array().items( - Joi.object({ - base: Joi.alternatives().when('spaces', { - is: allSpacesSchema, - then: Joi.array().items(Joi.string().valid(Object.keys(privileges.global))).empty(Joi.array().length(0)), - otherwise: Joi.array().items(Joi.string().valid(Object.keys(privileges.space))).empty(Joi.array().length(0)), - }), - feature: Joi.object() - .pattern(/^[a-zA-Z0-9_-]+$/, Joi.array().items(Joi.string().regex(/^[a-zA-Z0-9_-]+$/))) - .empty(Joi.object().length(0)), - spaces: Joi.alternatives( - allSpacesSchema, - Joi.array().items(Joi.string().regex(/^[a-z0-9_-]+$/)), - ).default([GLOBAL_RESOURCE]), - }) - // the following can be replaced with .oxor once we upgrade Joi - .without('base', ['feature']) - ).unique((a, b) => { - return intersection(a.spaces, b.spaces).length !== 0; - }); - }; - - const schema = Joi.object().keys({ - metadata: Joi.object().optional(), - elasticsearch: Joi.object().keys({ - cluster: Joi.array().items(Joi.string()), - indices: Joi.array().items({ - names: Joi.array().items(Joi.string()), - field_security: Joi.object().keys({ - grant: Joi.array().items(Joi.string()), - except: Joi.array().items(Joi.string()), - }), - privileges: Joi.array().items(Joi.string()), - query: Joi.string().allow(''), - allow_restricted_indices: Joi.boolean(), - }), - run_as: Joi.array().items(Joi.string()), - }), - kibana: Joi.lazy(() => getKibanaSchema()) - }); - - server.route({ - method: 'PUT', - path: '/api/security/role/{name}', - async handler(request, h) { - const { name } = request.params; - - try { - const existingRoleResponse = await callWithRequest(request, 'shield.getRole', { - name, - ignore: [404], - }); - - const body = transformRolesToEs( - request.payload, - existingRoleResponse[name] ? existingRoleResponse[name].applications : [] - ); - - await callWithRequest(request, 'shield.putRole', { name, body }); - return h.response().code(204); - } catch (err) { - throw wrapError(err); - } - }, - options: { - validate: { - params: Joi.object() - .keys({ - name: Joi.string() - .required() - .min(1) - .max(1024), - }) - .required(), - payload: schema, - }, - pre: [routePreCheckLicenseFn], - }, - }); -} diff --git a/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.test.js b/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.test.js deleted file mode 100644 index 01016b2b077c3..0000000000000 --- a/x-pack/legacy/plugins/security/server/routes/api/external/roles/put.test.js +++ /dev/null @@ -1,963 +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 Hapi from 'hapi'; -import Boom from 'boom'; -import { initPutRolesApi } from './put'; -import { defaultValidationErrorHandler } from '../../../../../../../../../src/core/server/http/http_tools'; -import { GLOBAL_RESOURCE } from '../../../../../common/constants'; - -const application = 'kibana-.kibana'; - -const createMockServer = () => { - const mockServer = new Hapi.Server({ - debug: false, - port: 8080, - routes: { - validate: { - failAction: defaultValidationErrorHandler - } - } - }); - return mockServer; -}; - -const defaultPreCheckLicenseImpl = () => null; - -const privilegeMap = { - global: { - all: [], - read: [], - }, - space: { - all: [], - read: [], - }, - features: { - foo: { - 'foo-privilege-1': [], - 'foo-privilege-2': [], - }, - bar: { - 'bar-privilege-1': [], - 'bar-privilege-2': [], - } - }, - reserved: { - customApplication1: [], - customApplication2: [], - } -}; - -const putRoleTest = ( - description, - { name, payload, preCheckLicenseImpl, callWithRequestImpls = [], asserts } -) => { - test(description, async () => { - const mockServer = createMockServer(); - const mockPreCheckLicense = jest - .fn() - .mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - for (const impl of callWithRequestImpls) { - mockCallWithRequest.mockImplementationOnce(impl); - } - const mockAuthorization = { - privileges: { - get: () => privilegeMap - } - }; - initPutRolesApi( - mockServer, - mockCallWithRequest, - mockPreCheckLicense, - mockAuthorization, - application, - ); - const headers = { - authorization: 'foo', - }; - - const request = { - method: 'PUT', - url: `/api/security/role/${name}`, - headers, - payload, - }; - const response = await mockServer.inject(request); - const { result, statusCode } = response; - - expect(result).toEqual(asserts.result); - expect(statusCode).toBe(asserts.statusCode); - if (preCheckLicenseImpl) { - expect(mockPreCheckLicense).toHaveBeenCalled(); - } else { - expect(mockPreCheckLicense).not.toHaveBeenCalled(); - } - if (asserts.callWithRequests) { - for (const args of asserts.callWithRequests) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), - ...args - ); - } - } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); - } - }); -}; - -describe('PUT role', () => { - describe('failure', () => { - putRoleTest(`requires name in params`, { - name: '', - payload: {}, - asserts: { - statusCode: 404, - result: { - error: 'Not Found', - message: 'Not Found', - statusCode: 404, - }, - }, - }); - - putRoleTest(`requires name in params to not exceed 1024 characters`, { - name: 'a'.repeat(1025), - payload: {}, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - message: `child "name" fails because ["name" length must be less than or equal to 1024 characters long]`, - statusCode: 400, - validation: { - keys: ['name'], - source: 'params', - }, - }, - }, - }); - - putRoleTest(`only allows features that match the pattern`, { - name: 'foo-role', - payload: { - kibana: [ - { - feature: { - '!foo': ['foo'] - } - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"feature\" fails because [\"!foo\" is not allowed]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.feature.!foo'], - source: 'payload', - }, - }, - }, - }); - - putRoleTest(`only allows feature privileges that match the pattern`, { - name: 'foo-role', - payload: { - kibana: [ - { - feature: { - foo: ['!foo'] - } - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"feature\" fails because [child \"foo\" fails because [\"foo\" at position 0 fails because [\"0\" with value \"!foo\" fails to match the required pattern: /^[a-zA-Z0-9_-]+$/]]]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.feature.foo.0'], - source: 'payload', - }, - }, - }, - }); - - putRoleTest(`doesn't allow both base and feature in the same entry`, { - name: 'foo-role', - payload: { - kibana: [ - { - base: ['all'], - feature: { - foo: ['foo'] - } - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [\"base\" conflict with forbidden peer \"feature\"]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.base'], - source: 'payload', - }, - }, - }, - }); - - describe('global', () => { - putRoleTest(`only allows known Kibana global base privileges`, { - name: 'foo-role', - payload: { - kibana: [ - { - base: ['foo'], - spaces: ['*'] - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"base\" fails because [\"base\" at position 0 fails because [\"0\" must be one of [all, read]]]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.base.0'], - source: 'payload', - }, - }, - }, - }); - - putRoleTest(`doesn't allow Kibana reserved privileges`, { - name: 'foo-role', - payload: { - kibana: [ - { - _reserved: ['customApplication1'], - spaces: ['*'] - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [\"_reserved\" is not allowed]]`, - statusCode: 400, - validation: { - keys: ['kibana.0._reserved'], - source: 'payload', - }, - }, - }, - }); - - putRoleTest(`only allows one global entry`, { - name: 'foo-role', - payload: { - kibana: [ - { - feature: { - foo: ['foo-privilege-1'] - }, - spaces: ['*'] - }, - { - feature: { - bar: ['bar-privilege-1'] - }, - spaces: ['*'] - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" position 1 contains a duplicate value]`, - statusCode: 400, - validation: { - keys: ['kibana.1'], - source: 'payload' - } - }, - }, - }); - }); - - describe('space', () => { - - putRoleTest(`doesn't allow * in a space ID`, { - name: 'foo-role', - payload: { - kibana: [ - { - spaces: ['foo-*'] - } - ], - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"spaces\" fails because [\"spaces\" at position 0 fails because [\"0\" must be one of [*]], \"spaces\" at position 0 fails because [\"0\" with value \"foo-*\" fails to match the required pattern: /^[a-z0-9_-]+$/]]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.spaces.0', 'kibana.0.spaces.0'], - source: 'payload', - }, - }, - }, - }); - - putRoleTest(`can't assign space and global in same entry`, { - name: 'foo-role', - payload: { - kibana: [ - { - spaces: ['*', 'foo-space'] - } - ], - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"spaces\" fails because [\"spaces\" at position 1 fails because [\"1\" must be one of [*]], \"spaces\" at position 0 fails because [\"0\" with value \"*\" fails to match the required pattern: /^[a-z0-9_-]+$/]]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.spaces.1', 'kibana.0.spaces.0'], - source: 'payload', - }, - }, - }, - }); - - putRoleTest(`only allows known Kibana space base privileges`, { - name: 'foo-role', - payload: { - kibana: [ - { - base: ['foo'], - spaces: ['foo-space'] - } - ], - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"base\" fails because [\"base\" at position 0 fails because [\"0\" must be one of [all, read]]]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.base.0'], - source: 'payload', - }, - }, - }, - }); - - putRoleTest(`only allows space to be in one entry`, { - name: 'foo-role', - payload: { - kibana: [ - { - feature: { - foo: ['foo-privilege-1'] - }, - spaces: ['marketing'] - }, - { - feature: { - bar: ['bar-privilege-1'] - }, - spaces: ['sales', 'marketing'] - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" position 1 contains a duplicate value]`, - statusCode: 400, - validation: { - keys: ['kibana.1'], - source: 'payload' - } - }, - }, - }); - - putRoleTest(`doesn't allow Kibana reserved privileges`, { - name: 'foo-role', - payload: { - kibana: [ - { - _reserved: ['customApplication1'], - spaces: ['marketing'] - }, - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [\"_reserved\" is not allowed]]`, - statusCode: 400, - validation: { - keys: ['kibana.0._reserved'], - source: 'payload' - } - }, - }, - }); - }); - - putRoleTest(`returns result of routePreCheckLicense`, { - name: 'foo-role', - payload: {}, - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, - }); - }); - - describe('success', () => { - putRoleTest(`creates empty role`, { - name: 'foo-role', - payload: {}, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => { }], - asserts: { - callWithRequests: [ - ['shield.getRole', { name: 'foo-role', ignore: [404] }], - [ - 'shield.putRole', - { - name: 'foo-role', - body: { - cluster: [], - indices: [], - run_as: [], - applications: [], - }, - }, - ], - ], - statusCode: 204, - result: null, - }, - }); - - putRoleTest(`if spaces isn't specifed, defaults to global`, { - name: 'foo-role', - payload: { - kibana: [ - { - base: ['all'], - } - ] - }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => { }], - asserts: { - callWithRequests: [ - ['shield.getRole', { name: 'foo-role', ignore: [404] }], - [ - 'shield.putRole', - { - name: 'foo-role', - body: { - cluster: [], - indices: [], - run_as: [], - applications: [ - { - application, - privileges: [ - 'all', - ], - resources: [GLOBAL_RESOURCE], - }, - ], - }, - }, - ], - ], - statusCode: 204, - result: null, - }, - }); - - putRoleTest(`allows base with empty array and feature in the same entry`, { - name: 'foo-role', - payload: { - kibana: [ - { - base: [], - feature: { - foo: ['foo'] - } - } - ] - }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => { }], - asserts: { - callWithRequests: [ - ['shield.getRole', { name: 'foo-role', ignore: [404] }], - [ - 'shield.putRole', - { - name: 'foo-role', - body: { - cluster: [], - indices: [], - run_as: [], - applications: [ - { - application, - privileges: [ - 'feature_foo.foo', - ], - resources: [GLOBAL_RESOURCE], - }, - ], - }, - }, - ], - ], - statusCode: 204, - result: null, - }, - }); - - putRoleTest(`allows base and feature with empty object in the same entry`, { - name: 'foo-role', - payload: { - kibana: [ - { - base: ['all'], - feature: {} - } - ] - }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => { }], - asserts: { - callWithRequests: [ - ['shield.getRole', { name: 'foo-role', ignore: [404] }], - [ - 'shield.putRole', - { - name: 'foo-role', - body: { - cluster: [], - indices: [], - run_as: [], - applications: [ - { - application, - privileges: [ - 'all', - ], - resources: [GLOBAL_RESOURCE], - }, - ], - }, - }, - ], - ], - statusCode: 204, - result: null, - }, - }); - - putRoleTest(`creates role with everything`, { - name: 'foo-role', - payload: { - metadata: { - foo: 'test-metadata', - }, - elasticsearch: { - cluster: ['test-cluster-privilege'], - indices: [ - { - field_security: { - grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: ['test-field-security-except-1', 'test-field-security-except-2'] - }, - names: ['test-index-name-1', 'test-index-name-2'], - privileges: ['test-index-privilege-1', 'test-index-privilege-2'], - query: `{ "match": { "title": "foo" } }`, - }, - ], - run_as: ['test-run-as-1', 'test-run-as-2'], - }, - kibana: [ - { - base: ['all', 'read'], - spaces: ['*'], - }, - { - base: ['all', 'read'], - spaces: ['test-space-1', 'test-space-2'] - }, - { - feature: { - foo: ['foo-privilege-1', 'foo-privilege-2'], - }, - spaces: ['test-space-3'] - } - ] - }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => { }], - asserts: { - callWithRequests: [ - ['shield.getRole', { name: 'foo-role', ignore: [404] }], - [ - 'shield.putRole', - { - name: 'foo-role', - body: { - applications: [ - { - application, - privileges: [ - 'all', - 'read', - ], - resources: [GLOBAL_RESOURCE], - }, - { - application, - privileges: [ - 'space_all', - 'space_read', - ], - resources: ['space:test-space-1', 'space:test-space-2'] - }, - { - application, - privileges: [ - 'feature_foo.foo-privilege-1', - 'feature_foo.foo-privilege-2', - ], - resources: ['space:test-space-3'] - }, - ], - cluster: ['test-cluster-privilege'], - indices: [ - { - field_security: { - grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: ['test-field-security-except-1', 'test-field-security-except-2'] - }, - names: ['test-index-name-1', 'test-index-name-2'], - privileges: [ - 'test-index-privilege-1', - 'test-index-privilege-2', - ], - query: `{ "match": { "title": "foo" } }`, - }, - ], - metadata: { foo: 'test-metadata' }, - run_as: ['test-run-as-1', 'test-run-as-2'], - }, - }, - ], - ], - statusCode: 204, - result: null, - }, - }); - - putRoleTest(`updates role which has existing kibana privileges`, { - name: 'foo-role', - payload: { - metadata: { - foo: 'test-metadata', - }, - elasticsearch: { - cluster: ['test-cluster-privilege'], - indices: [ - { - field_security: { - grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: ['test-field-security-except-1', 'test-field-security-except-2'] - }, - names: ['test-index-name-1', 'test-index-name-2'], - privileges: ['test-index-privilege-1', 'test-index-privilege-2'], - query: `{ "match": { "title": "foo" } }`, - }, - ], - run_as: ['test-run-as-1', 'test-run-as-2'], - }, - kibana: [ - { - feature: { - foo: ['foo-privilege-1'], - bar: ['bar-privilege-1'] - }, - spaces: ['*'] - }, - { - base: ['all'], - spaces: ['test-space-1', 'test-space-2'] - }, - { - feature: { - bar: ['bar-privilege-2'] - }, - spaces: ['test-space-3'] - } - ], - }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [ - async () => ({ - 'foo-role': { - metadata: { - bar: 'old-metadata', - }, - transient_metadata: { - enabled: true, - }, - cluster: ['old-cluster-privilege'], - indices: [ - { - field_security: { - grant: ['old-field-security-grant-1', 'old-field-security-grant-2'], - except: ['old-field-security-except-1', 'old-field-security-except-2'] - }, - names: ['old-index-name'], - privileges: ['old-privilege'], - query: `{ "match": { "old-title": "foo" } }`, - }, - ], - run_as: ['old-run-as'], - applications: [ - { - application, - privileges: ['old-kibana-privilege'], - resources: ['old-resource'], - }, - ], - }, - }), - async () => { }, - ], - asserts: { - callWithRequests: [ - ['shield.getRole', { name: 'foo-role', ignore: [404] }], - [ - 'shield.putRole', - { - name: 'foo-role', - body: { - applications: [ - { - application, - privileges: [ - 'feature_foo.foo-privilege-1', - 'feature_bar.bar-privilege-1', - ], - resources: [GLOBAL_RESOURCE], - }, - { - application, - privileges: [ - 'space_all', - ], - resources: ['space:test-space-1', 'space:test-space-2'] - }, - { - application, - privileges: [ - 'feature_bar.bar-privilege-2', - ], - resources: ['space:test-space-3'] - }, - ], - cluster: ['test-cluster-privilege'], - indices: [ - { - field_security: { - grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: ['test-field-security-except-1', 'test-field-security-except-2'] - }, - names: ['test-index-name-1', 'test-index-name-2'], - privileges: [ - 'test-index-privilege-1', - 'test-index-privilege-2', - ], - query: `{ "match": { "title": "foo" } }`, - }, - ], - metadata: { foo: 'test-metadata' }, - run_as: ['test-run-as-1', 'test-run-as-2'], - }, - }, - ], - ], - statusCode: 204, - result: null, - }, - }); - - putRoleTest( - `updates role which has existing other application privileges`, - { - name: 'foo-role', - payload: { - metadata: { - foo: 'test-metadata', - }, - elasticsearch: { - cluster: ['test-cluster-privilege'], - indices: [ - { - names: ['test-index-name-1', 'test-index-name-2'], - privileges: [ - 'test-index-privilege-1', - 'test-index-privilege-2', - ], - }, - ], - run_as: ['test-run-as-1', 'test-run-as-2'], - }, - kibana: [ - { - base: ['all', 'read'], - spaces: ['*'] - } - ] - }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [ - async () => ({ - 'foo-role': { - metadata: { - bar: 'old-metadata', - }, - transient_metadata: { - enabled: true, - }, - cluster: ['old-cluster-privilege'], - indices: [ - { - names: ['old-index-name'], - privileges: ['old-privilege'], - }, - ], - run_as: ['old-run-as'], - applications: [ - { - application, - privileges: ['old-kibana-privilege'], - resources: ['old-resource'], - }, - { - application: 'logstash-foo', - privileges: ['logstash-privilege'], - resources: ['logstash-resource'], - }, - { - application: 'beats-foo', - privileges: ['beats-privilege'], - resources: ['beats-resource'], - }, - ], - }, - }), - async () => { }, - ], - asserts: { - callWithRequests: [ - ['shield.getRole', { name: 'foo-role', ignore: [404] }], - [ - 'shield.putRole', - { - name: 'foo-role', - body: { - applications: [ - { - application, - privileges: [ - 'all', - 'read', - ], - resources: [GLOBAL_RESOURCE], - }, - { - application: 'logstash-foo', - privileges: ['logstash-privilege'], - resources: ['logstash-resource'], - }, - { - application: 'beats-foo', - privileges: ['beats-privilege'], - resources: ['beats-resource'], - }, - ], - cluster: ['test-cluster-privilege'], - indices: [ - { - names: ['test-index-name-1', 'test-index-name-2'], - privileges: [ - 'test-index-privilege-1', - 'test-index-privilege-2', - ], - }, - ], - metadata: { foo: 'test-metadata' }, - run_as: ['test-run-as-1', 'test-run-as-2'], - }, - }, - ], - ], - statusCode: 204, - result: null, - }, - } - ); - }); -}); diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js index ade1f0974096c..fc55bdcc38661 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js @@ -14,10 +14,7 @@ export function initApiKeysApi(server) { const callWithRequest = getClient(server).callWithRequest; const routePreCheckLicenseFn = routePreCheckLicense(server); - const { authorization } = server.plugins.security; - const { application } = authorization; - - initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn, application); - initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn, application); - initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn, application); + initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn); + initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn); + initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn); } diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js index d22cf0aef4db7..f37c9a2fd917f 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js @@ -11,7 +11,7 @@ import { canRedirectRequest, wrapError, OIDCAuthenticationFlow } from '../../../ import { KibanaRequest } from '../../../../../../../../src/core/server'; import { createCSPRuleString } from '../../../../../../../../src/legacy/server/csp'; -export function initAuthenticateApi({ authc: { login, logout }, config }, server) { +export function initAuthenticateApi({ authc: { login, logout }, __legacyCompat: { config } }, server) { function prepareCustomResourceResponse(response, contentType) { return response .header('cache-control', 'private, no-cache, no-store') diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js index 1d47dc8875348..595182653fa23 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js @@ -13,7 +13,7 @@ import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; import { wrapError } from '../../../../../../../plugins/security/server'; import { KibanaRequest } from '../../../../../../../../src/core/server'; -export function initUsersApi({ authc: { login }, config }, server) { +export function initUsersApi({ authc: { login }, __legacyCompat: { config } }, server) { const callWithRequest = getClient(server).callWithRequest; const routePreCheckLicenseFn = routePreCheckLicense(server); diff --git a/x-pack/legacy/plugins/security/server/routes/views/logged_out.js b/x-pack/legacy/plugins/security/server/routes/views/logged_out.js index 51867631b57be..25905aaab6f3f 100644 --- a/x-pack/legacy/plugins/security/server/routes/views/logged_out.js +++ b/x-pack/legacy/plugins/security/server/routes/views/logged_out.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export function initLoggedOutView({ config: { cookieName } }, server) { +export function initLoggedOutView({ __legacyCompat: { config: { cookieName } } }, server) { const config = server.config(); const loggedOut = server.getHiddenUiAppById('logged_out'); diff --git a/x-pack/legacy/plugins/security/server/routes/views/login.js b/x-pack/legacy/plugins/security/server/routes/views/login.js index f7e7f2933efcc..7e2b50b40f727 100644 --- a/x-pack/legacy/plugins/security/server/routes/views/login.js +++ b/x-pack/legacy/plugins/security/server/routes/views/login.js @@ -8,16 +8,13 @@ import { get } from 'lodash'; import { parseNext } from '../../lib/parse_next'; -export function initLoginView({ config: { cookieName } }, server, xpackMainPlugin) { +export function initLoginView({ __legacyCompat: { config: { cookieName }, license } }, server) { const config = server.config(); const login = server.getHiddenUiAppById('login'); function shouldShowLogin() { - if (xpackMainPlugin && xpackMainPlugin.info) { - const licenseCheckResults = xpackMainPlugin.info.feature('security').getLicenseCheckResults(); - if (licenseCheckResults) { - return Boolean(licenseCheckResults.showLogin); - } + if (license.isEnabled()) { + return Boolean(license.getFeatures().showLogin); } // default to true if xpack info isn't available or diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_overview.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_overview.tsx index c1168a44aa6be..e0789ac9e2558 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_overview.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_overview.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { RouteComponentProps } from 'react-router'; +import { RouteComponentProps } from 'react-router-dom'; import { RedirectWrapper } from './redirect_wrapper'; import { SiemPageName } from '../../pages/home/types'; diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_timelines.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_timelines.tsx index 153166f00a0c0..1b71432b3f729 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_timelines.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_timelines.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { RouteComponentProps } from 'react-router'; +import { RouteComponentProps } from 'react-router-dom'; import { RedirectWrapper } from './redirect_wrapper'; import { SiemPageName } from '../../pages/home/types'; diff --git a/x-pack/legacy/plugins/spaces/index.ts b/x-pack/legacy/plugins/spaces/index.ts index a92fdcb9304cd..598d115a39e49 100644 --- a/x-pack/legacy/plugins/spaces/index.ts +++ b/x-pack/legacy/plugins/spaces/index.ts @@ -10,13 +10,11 @@ import { Legacy } from 'kibana'; import { KibanaRequest } from '../../../../src/core/server'; import { SpacesServiceSetup } from '../../../plugins/spaces/server/spaces_service/spaces_service'; import { SpacesPluginSetup } from '../../../plugins/spaces/server'; -import { createOptionalPlugin } from '../../server/lib/optional_plugin'; // @ts-ignore import { AuditLogger } from '../../server/lib/audit_logger'; import mappings from './mappings.json'; import { wrapError } from './server/lib/errors'; import { migrateToKibana660 } from './server/lib/migrations'; -import { SecurityPlugin } from '../security'; // @ts-ignore import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; import { initSpaceSelectorView, initEnterSpaceView } from './server/routes/views'; @@ -139,12 +137,6 @@ export const spaces = (kibana: Record) => create: (pluginId: string) => new AuditLogger(server, pluginId, server.config(), server.plugins.xpack_main.info), }, - security: createOptionalPlugin( - server.config(), - 'xpack.security', - server.plugins, - 'security' - ), xpackMain: server.plugins.xpack_main, }); diff --git a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx index a2f3fa1204eca..66cb5d29d1742 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/hooks/__tests__/use_url_params.test.tsx @@ -8,7 +8,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import DateMath from '@elastic/datemath'; import React, { useState, Fragment } from 'react'; import { useUrlParams, UptimeUrlParamsHook } from '../use_url_params'; -import { RouteComponentProps } from 'react-router'; +import { RouteComponentProps } from 'react-router-dom'; import { UptimeRefreshContext } from '../../contexts'; interface MockUrlParamsComponentProps { diff --git a/x-pack/legacy/server/lib/__snapshots__/optional_plugin.test.ts.snap b/x-pack/legacy/server/lib/__snapshots__/optional_plugin.test.ts.snap deleted file mode 100644 index a57512e7e8dc6..0000000000000 --- a/x-pack/legacy/server/lib/__snapshots__/optional_plugin.test.ts.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`throws error when invoked before it's available 1`] = `"Plugin accessed before it's available"`; - -exports[`throws error when invoked before it's available 2`] = `"Plugin accessed before it's available"`; - -exports[`throws error when invoked before it's available 3`] = `"Plugin accessed before it's available"`; - -exports[`throws error when invoked when it's not enabled 1`] = `"Plugin isn't enabled, check isEnabled before using"`; - -exports[`throws error when invoked when it's not enabled 2`] = `"Plugin isn't enabled, check isEnabled before using"`; - -exports[`throws error when invoked when it's not enabled 3`] = `"Plugin isn't enabled, check isEnabled before using"`; diff --git a/x-pack/legacy/server/lib/optional_plugin.test.ts b/x-pack/legacy/server/lib/optional_plugin.test.ts deleted file mode 100644 index 8645a61cb8fd1..0000000000000 --- a/x-pack/legacy/server/lib/optional_plugin.test.ts +++ /dev/null @@ -1,85 +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 { createOptionalPlugin } from './optional_plugin'; - -class FooPlugin { - public get aProp() { - return 'a prop'; - } - - public aField = 'a field'; - - public aMethod() { - return 'a method'; - } -} - -const createMockConfig = (settings: Record) => { - return { - get: (key: string) => { - if (!Object.keys(settings).includes(key)) { - throw new Error('Unknown config key'); - } - - return settings[key]; - }, - }; -}; - -describe('isEnabled', () => { - test('returns true when config.get(`${configPrefix}.enabled`) is true', () => { - const config = createMockConfig({ 'xpack.fooPlugin.enabled': true }); - const conditionalFooPlugin = createOptionalPlugin(config, 'xpack.fooPlugin', {}, 'fooPlugin'); - expect(conditionalFooPlugin.isEnabled).toBe(true); - }); - - test('returns false when config.get(`${configPrefix}.enabled`) is false', () => { - const config = createMockConfig({ 'xpack.fooPlugin.enabled': false }); - const conditionalFooPlugin = createOptionalPlugin(config, 'xpack.fooPlugin', {}, 'fooPlugin'); - expect(conditionalFooPlugin.isEnabled).toBe(false); - }); -}); - -test(`throws error when invoked before it's available`, () => { - const config = createMockConfig({ 'xpack.fooPlugin.enabled': true }); - const conditionalFooPlugin = createOptionalPlugin( - config, - 'xpack.fooPlugin', - {}, - 'fooPlugin' - ); - expect(() => conditionalFooPlugin.aProp).toThrowErrorMatchingSnapshot(); - expect(() => conditionalFooPlugin.aMethod()).toThrowErrorMatchingSnapshot(); - expect(() => conditionalFooPlugin.aField).toThrowErrorMatchingSnapshot(); -}); - -test(`throws error when invoked when it's not enabled`, () => { - const config = createMockConfig({ 'xpack.fooPlugin.enabled': false }); - const conditionalFooPlugin = createOptionalPlugin( - config, - 'xpack.fooPlugin', - {}, - 'fooPlugin' - ); - expect(() => conditionalFooPlugin.aProp).toThrowErrorMatchingSnapshot(); - expect(() => conditionalFooPlugin.aMethod()).toThrowErrorMatchingSnapshot(); - expect(() => conditionalFooPlugin.aField).toThrowErrorMatchingSnapshot(); -}); - -test(`behaves normally when it's enabled and available`, () => { - const config = createMockConfig({ 'xpack.fooPlugin.enabled': false }); - const conditionalFooPlugin = createOptionalPlugin( - config, - 'xpack.fooPlugin', - { - fooPlugin: new FooPlugin(), - }, - 'fooPlugin' - ); - expect(conditionalFooPlugin.aProp).toBe('a prop'); - expect(conditionalFooPlugin.aMethod()).toBe('a method'); - expect(conditionalFooPlugin.aField).toBe('a field'); -}); diff --git a/x-pack/legacy/server/lib/optional_plugin.ts b/x-pack/legacy/server/lib/optional_plugin.ts deleted file mode 100644 index 16522091d01cf..0000000000000 --- a/x-pack/legacy/server/lib/optional_plugin.ts +++ /dev/null @@ -1,48 +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. - */ - -interface Config { - get(key: string): any; -} - -interface Plugins { - [key: string]: any; -} - -interface IsEnabled { - isEnabled: boolean; -} - -export type OptionalPlugin = IsEnabled & T; - -export function createOptionalPlugin( - config: Config, - configPrefix: string, - plugins: Plugins, - pluginId: string -): OptionalPlugin { - return new Proxy( - {}, - { - get(obj, prop) { - const isEnabled = config.get(`${configPrefix}.enabled`); - if (prop === 'isEnabled') { - return isEnabled; - } - - if (!plugins[pluginId] && isEnabled) { - throw new Error(`Plugin accessed before it's available`); - } - - if (!plugins[pluginId] && !isEnabled) { - throw new Error(`Plugin isn't enabled, check isEnabled before using`); - } - - return plugins[pluginId][prop]; - }, - } - ) as OptionalPlugin; -} diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts new file mode 100644 index 0000000000000..44b6601daa7ff --- /dev/null +++ b/x-pack/plugins/security/common/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const GLOBAL_RESOURCE = '*'; +export const APPLICATION_PREFIX = 'kibana-'; +export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; diff --git a/x-pack/legacy/plugins/security/common/model/builtin_es_privileges.ts b/x-pack/plugins/security/common/model/builtin_es_privileges.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/builtin_es_privileges.ts rename to x-pack/plugins/security/common/model/builtin_es_privileges.ts diff --git a/x-pack/legacy/plugins/security/common/model/features_privileges.ts b/x-pack/plugins/security/common/model/features_privileges.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/features_privileges.ts rename to x-pack/plugins/security/common/model/features_privileges.ts diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 00b17548c47ac..c6ccd2518d261 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -6,3 +6,8 @@ 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 { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role'; +export { KibanaPrivileges } from './kibana_privileges'; diff --git a/x-pack/legacy/plugins/security/common/model/kibana_privileges/feature_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/kibana_privileges/feature_privileges.ts rename to x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts diff --git a/x-pack/legacy/plugins/security/common/model/kibana_privileges/global_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/kibana_privileges/global_privileges.ts rename to x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts diff --git a/x-pack/legacy/plugins/security/common/model/kibana_privileges/index.ts b/x-pack/plugins/security/common/model/kibana_privileges/index.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/kibana_privileges/index.ts rename to x-pack/plugins/security/common/model/kibana_privileges/index.ts diff --git a/x-pack/legacy/plugins/security/common/model/kibana_privileges/kibana_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/kibana_privileges/kibana_privileges.ts rename to x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts diff --git a/x-pack/legacy/plugins/security/common/model/kibana_privileges/spaces_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/kibana_privileges/spaces_privileges.ts rename to x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts diff --git a/x-pack/legacy/plugins/security/common/model/raw_kibana_privileges.ts b/x-pack/plugins/security/common/model/raw_kibana_privileges.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/raw_kibana_privileges.ts rename to x-pack/plugins/security/common/model/raw_kibana_privileges.ts diff --git a/x-pack/legacy/plugins/security/common/model/role.ts b/x-pack/plugins/security/common/model/role.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/model/role.ts rename to x-pack/plugins/security/common/model/role.ts diff --git a/x-pack/legacy/plugins/security/common/privilege_calculator_utils.test.ts b/x-pack/plugins/security/common/privilege_calculator_utils.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/privilege_calculator_utils.test.ts rename to x-pack/plugins/security/common/privilege_calculator_utils.test.ts diff --git a/x-pack/legacy/plugins/security/common/privilege_calculator_utils.ts b/x-pack/plugins/security/common/privilege_calculator_utils.ts similarity index 100% rename from x-pack/legacy/plugins/security/common/privilege_calculator_utils.ts rename to x-pack/plugins/security/common/privilege_calculator_utils.ts diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 9f243a7dfb2fc..32f860b1423d3 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -3,6 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "security"], + "requiredPlugins": ["features", "licensing"], "server": true, "ui": true } diff --git a/x-pack/legacy/plugins/security/server/lib/audit_logger.test.js b/x-pack/plugins/security/server/audit/audit_logger.test.ts similarity index 90% rename from x-pack/legacy/plugins/security/server/lib/audit_logger.test.js rename to x-pack/plugins/security/server/audit/audit_logger.test.ts index 716946adab41c..2ae8b6762c5d4 100644 --- a/x-pack/legacy/plugins/security/server/lib/audit_logger.test.js +++ b/x-pack/plugins/security/server/audit/audit_logger.test.ts @@ -7,22 +7,21 @@ import { SecurityAuditLogger } from './audit_logger'; const createMockAuditLogger = () => { return { - log: jest.fn() + log: jest.fn(), }; }; describe(`#savedObjectsAuthorizationFailure`, () => { - test('logs via auditLogger', () => { const auditLogger = createMockAuditLogger(); const securityAuditLogger = new SecurityAuditLogger(auditLogger); const username = 'foo-user'; const action = 'foo-action'; - const types = [ 'foo-type-1', 'foo-type-2' ]; + const types = ['foo-type-1', 'foo-type-2']; const missing = [`saved_object:${types[0]}/foo-action`, `saved_object:${types[1]}/foo-action`]; const args = { - 'foo': 'bar', - 'baz': 'quz', + foo: 'bar', + baz: 'quz', }; securityAuditLogger.savedObjectsAuthorizationFailure(username, action, types, missing, args); @@ -47,10 +46,10 @@ describe(`#savedObjectsAuthorizationSuccess`, () => { const securityAuditLogger = new SecurityAuditLogger(auditLogger); const username = 'foo-user'; const action = 'foo-action'; - const types = [ 'foo-type-1', 'foo-type-2' ]; + const types = ['foo-type-1', 'foo-type-2']; const args = { - 'foo': 'bar', - 'baz': 'quz', + foo: 'bar', + baz: 'quz', }; securityAuditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); diff --git a/x-pack/legacy/plugins/security/server/lib/audit_logger.js b/x-pack/plugins/security/server/audit/audit_logger.ts similarity index 59% rename from x-pack/legacy/plugins/security/server/lib/audit_logger.js rename to x-pack/plugins/security/server/audit/audit_logger.ts index 1326aaf3ee4b5..4c2c57d0e029e 100644 --- a/x-pack/legacy/plugins/security/server/lib/audit_logger.js +++ b/x-pack/plugins/security/server/audit/audit_logger.ts @@ -4,13 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { LegacyAPI } from '../plugin'; + export class SecurityAuditLogger { - constructor(auditLogger) { - this._auditLogger = auditLogger; - } + constructor(private readonly auditLogger: LegacyAPI['auditLogger']) {} - savedObjectsAuthorizationFailure(username, action, types, missing, args) { - this._auditLogger.log( + savedObjectsAuthorizationFailure( + username: string, + action: string, + types: string[], + missing: string[], + args?: Record + ) { + this.auditLogger.log( 'saved_objects_authorization_failure', `${username} unauthorized to ${action} ${types.join(',')}, missing ${missing.join(',')}`, { @@ -18,13 +24,18 @@ export class SecurityAuditLogger { action, types, missing, - args + args, } ); } - savedObjectsAuthorizationSuccess(username, action, types, args) { - this._auditLogger.log( + savedObjectsAuthorizationSuccess( + username: string, + action: string, + types: string[], + args?: Record + ) { + this.auditLogger.log( 'saved_objects_authorization_success', `${username} authorized to ${action} ${types.join(',')}`, { diff --git a/x-pack/plugins/security/server/audit/index.mock.ts b/x-pack/plugins/security/server/audit/index.mock.ts new file mode 100644 index 0000000000000..c14b98ed4781e --- /dev/null +++ b/x-pack/plugins/security/server/audit/index.mock.ts @@ -0,0 +1,16 @@ +/* + * 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 { SecurityAuditLogger } from './audit_logger'; + +export const securityAuditLoggerMock = { + create() { + return ({ + savedObjectsAuthorizationFailure: jest.fn(), + savedObjectsAuthorizationSuccess: jest.fn(), + } as unknown) as jest.Mocked; + }, +}; diff --git a/x-pack/plugins/security/server/audit/index.ts b/x-pack/plugins/security/server/audit/index.ts new file mode 100644 index 0000000000000..3ab253151b805 --- /dev/null +++ b/x-pack/plugins/security/server/audit/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SecurityAuditLogger } from './audit_logger'; diff --git a/x-pack/plugins/security/server/authentication/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys.test.ts index 7ecff1682465c..3fca1007413d4 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.test.ts @@ -4,19 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { APIKeys } from './api_keys'; import { IClusterClient, IScopedClusterClient } from '../../../../../src/core/server'; +import { SecurityLicense } from '../licensing'; +import { APIKeys } from './api_keys'; + import { httpServerMock, loggingServiceMock, elasticsearchServiceMock, } from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../licensing/index.mock'; describe('API Keys', () => { let apiKeys: APIKeys; let mockClusterClient: jest.Mocked; let mockScopedClusterClient: jest.Mocked; - const mockIsSecurityFeatureDisabled = jest.fn(); + let mockLicense: jest.Mocked; beforeEach(() => { mockClusterClient = elasticsearchServiceMock.createClusterClient(); @@ -24,17 +27,20 @@ describe('API Keys', () => { mockClusterClient.asScoped.mockReturnValue((mockScopedClusterClient as unknown) as jest.Mocked< IScopedClusterClient >); - mockIsSecurityFeatureDisabled.mockReturnValue(false); + + mockLicense = licenseMock.create(); + mockLicense.isEnabled.mockReturnValue(true); + apiKeys = new APIKeys({ clusterClient: mockClusterClient, logger: loggingServiceMock.create().get('api-keys'), - isSecurityFeatureDisabled: mockIsSecurityFeatureDisabled, + license: mockLicense, }); }); describe('create()', () => { it('returns null when security feature is disabled', async () => { - mockIsSecurityFeatureDisabled.mockReturnValue(true); + mockLicense.isEnabled.mockReturnValue(false); const result = await apiKeys.create(httpServerMock.createKibanaRequest(), { name: '', role_descriptors: {}, @@ -44,7 +50,7 @@ describe('API Keys', () => { }); it('calls callCluster with proper parameters', async () => { - mockIsSecurityFeatureDisabled.mockReturnValue(false); + mockLicense.isEnabled.mockReturnValue(true); mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({ id: '123', name: 'key-name', @@ -77,7 +83,7 @@ describe('API Keys', () => { describe('invalidate()', () => { it('returns null when security feature is disabled', async () => { - mockIsSecurityFeatureDisabled.mockReturnValue(true); + mockLicense.isEnabled.mockReturnValue(false); const result = await apiKeys.invalidate(httpServerMock.createKibanaRequest(), { id: '123', }); @@ -86,7 +92,7 @@ describe('API Keys', () => { }); it('calls callCluster with proper parameters', async () => { - mockIsSecurityFeatureDisabled.mockReturnValue(false); + mockLicense.isEnabled.mockReturnValue(true); mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({ invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], @@ -111,7 +117,7 @@ describe('API Keys', () => { }); it(`Only passes id as a parameter`, async () => { - mockIsSecurityFeatureDisabled.mockReturnValue(false); + mockLicense.isEnabled.mockReturnValue(true); mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({ invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], diff --git a/x-pack/plugins/security/server/authentication/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys.ts index 3709e8e7195fe..b207e227c56af 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.ts @@ -5,6 +5,7 @@ */ import { IClusterClient, KibanaRequest, Logger } from '../../../../../src/core/server'; +import { SecurityLicense } from '../licensing'; /** * Represents the options to create an APIKey class instance that will be @@ -13,7 +14,7 @@ import { IClusterClient, KibanaRequest, Logger } from '../../../../../src/core/s export interface ConstructorOptions { logger: Logger; clusterClient: IClusterClient; - isSecurityFeatureDisabled: () => boolean; + license: SecurityLicense; } /** @@ -92,12 +93,12 @@ export interface InvalidateAPIKeyResult { export class APIKeys { private readonly logger: Logger; private readonly clusterClient: IClusterClient; - private readonly isSecurityFeatureDisabled: () => boolean; + private readonly license: SecurityLicense; - constructor({ logger, clusterClient, isSecurityFeatureDisabled }: ConstructorOptions) { + constructor({ logger, clusterClient, license }: ConstructorOptions) { this.logger = logger; this.clusterClient = clusterClient; - this.isSecurityFeatureDisabled = isSecurityFeatureDisabled; + this.license = license; } /** @@ -109,7 +110,7 @@ export class APIKeys { request: KibanaRequest, params: CreateAPIKeyParams ): Promise { - if (this.isSecurityFeatureDisabled()) { + if (!this.license.isEnabled()) { return null; } @@ -139,7 +140,7 @@ export class APIKeys { request: KibanaRequest, params: InvalidateAPIKeyParams ): Promise { - if (this.isSecurityFeatureDisabled()) { + if (!this.license.isEnabled()) { return null; } diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 9342cce577dfb..ff7cf876adbef 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { licenseMock } from '../licensing/index.mock'; + jest.mock('./api_keys'); jest.mock('./authenticator'); @@ -41,32 +43,19 @@ import { InvalidateAPIKeyResult, InvalidateAPIKeyParams, } from './api_keys'; - -function mockXPackFeature({ isEnabled = true }: Partial<{ isEnabled: boolean }> = {}) { - return { - isEnabled: jest.fn().mockReturnValue(isEnabled), - isAvailable: jest.fn().mockReturnValue(true), - registerLicenseCheckResultsGenerator: jest.fn(), - getLicenseCheckResults: jest.fn(), - }; -} +import { SecurityLicense } from '../licensing'; describe('setupAuthentication()', () => { let mockSetupAuthenticationParams: { config: ConfigType; loggers: LoggerFactory; - getLegacyAPI(): LegacyAPI; - core: MockedKeys; + getLegacyAPI(): Pick; + http: jest.Mocked; clusterClient: jest.Mocked; + license: jest.Mocked; }; - let mockXpackInfo: jest.Mocked; let mockScopedClusterClient: jest.Mocked>; beforeEach(async () => { - mockXpackInfo = { - isAvailable: jest.fn().mockReturnValue(true), - feature: jest.fn().mockReturnValue(mockXPackFeature()), - }; - const mockConfig$ = createConfig$( coreMock.createPluginInitializerContext({ encryptionKey: 'ab'.repeat(16), @@ -77,11 +66,12 @@ describe('setupAuthentication()', () => { true ); mockSetupAuthenticationParams = { - core: coreMock.createSetup(), + http: coreMock.createSetup().http, config: await mockConfig$.pipe(first()).toPromise(), clusterClient: elasticsearchServiceMock.createClusterClient(), + license: licenseMock.create(), loggers: loggingServiceMock.create(), - getLegacyAPI: jest.fn().mockReturnValue({ xpackInfo: mockXpackInfo }), + getLegacyAPI: jest.fn(), }; mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); @@ -102,16 +92,16 @@ describe('setupAuthentication()', () => { await setupAuthentication(mockSetupAuthenticationParams); - expect(mockSetupAuthenticationParams.core.http.registerAuth).toHaveBeenCalledTimes(1); - expect(mockSetupAuthenticationParams.core.http.registerAuth).toHaveBeenCalledWith( + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( expect.any(Function) ); expect( - mockSetupAuthenticationParams.core.http.createCookieSessionStorageFactory + mockSetupAuthenticationParams.http.createCookieSessionStorageFactory ).toHaveBeenCalledTimes(1); expect( - mockSetupAuthenticationParams.core.http.createCookieSessionStorageFactory + mockSetupAuthenticationParams.http.createCookieSessionStorageFactory ).toHaveBeenCalledWith({ encryptionKey: config.encryptionKey, isSecure: config.secureCookies, @@ -129,7 +119,7 @@ describe('setupAuthentication()', () => { await setupAuthentication(mockSetupAuthenticationParams); - authHandler = mockSetupAuthenticationParams.core.http.registerAuth.mock.calls[0][0]; + authHandler = mockSetupAuthenticationParams.http.registerAuth.mock.calls[0][0]; authenticate = jest.requireMock('./authenticator').Authenticator.mock.instances[0] .authenticate; }); @@ -138,7 +128,7 @@ describe('setupAuthentication()', () => { const mockRequest = httpServerMock.createKibanaRequest(); const mockResponse = httpServerMock.createLifecycleResponseFactory(); - mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); + mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); await authHandler(mockRequest, mockResponse, mockAuthToolkit); @@ -302,7 +292,7 @@ describe('setupAuthentication()', () => { }); it('returns `null` if Security is disabled', async () => { - mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); + mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); await expect(getCurrentUser(httpServerMock.createKibanaRequest())).resolves.toBe(null); }); @@ -331,7 +321,7 @@ describe('setupAuthentication()', () => { }); it('returns `true` if Security is disabled', async () => { - mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); + mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); await expect(isAuthenticated(httpServerMock.createKibanaRequest())).resolves.toBe(true); }); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 9553ddd09b2c1..df16dd375e858 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -16,6 +16,7 @@ import { getErrorStatusCode } from '../errors'; import { Authenticator, ProviderSession } from './authenticator'; import { LegacyAPI } from '../plugin'; import { APIKeys, CreateAPIKeyParams, InvalidateAPIKeyParams } from './api_keys'; +import { SecurityLicense } from '../licensing'; export { canRedirectRequest } from './can_redirect_request'; export { Authenticator, ProviderLoginAttempt } from './authenticator'; @@ -30,35 +31,32 @@ export { } from './api_keys'; interface SetupAuthenticationParams { - core: CoreSetup; + http: CoreSetup['http']; clusterClient: IClusterClient; config: ConfigType; + license: SecurityLicense; loggers: LoggerFactory; - getLegacyAPI(): LegacyAPI; + getLegacyAPI(): Pick; } export type Authentication = UnwrapPromise>; export async function setupAuthentication({ - core, + http, clusterClient, config, + license, loggers, getLegacyAPI, }: SetupAuthenticationParams) { const authLogger = loggers.get('authentication'); - const isSecurityFeatureDisabled = () => { - const xpackInfo = getLegacyAPI().xpackInfo; - return xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled(); - }; - /** * Retrieves currently authenticated user associated with the specified request. * @param request */ const getCurrentUser = async (request: KibanaRequest) => { - if (isSecurityFeatureDisabled()) { + if (!license.isEnabled()) { return null; } @@ -69,11 +67,11 @@ export async function setupAuthentication({ const authenticator = new Authenticator({ clusterClient, - basePath: core.http.basePath, + basePath: http.basePath, config: { sessionTimeout: config.sessionTimeout, authc: config.authc }, isSystemAPIRequest: (request: KibanaRequest) => getLegacyAPI().isSystemAPIRequest(request), loggers, - sessionStorageFactory: await core.http.createCookieSessionStorageFactory({ + sessionStorageFactory: await http.createCookieSessionStorageFactory({ encryptionKey: config.encryptionKey, isSecure: config.secureCookies, name: config.cookieName, @@ -84,9 +82,9 @@ export async function setupAuthentication({ authLogger.debug('Successfully initialized authenticator.'); - core.http.registerAuth(async (request, response, t) => { + http.registerAuth(async (request, response, t) => { // If security is disabled continue with no user credentials and delete the client cookie as well. - if (isSecurityFeatureDisabled()) { + if (!license.isEnabled()) { return t.authenticated(); } @@ -148,7 +146,7 @@ export async function setupAuthentication({ const apiKeys = new APIKeys({ clusterClient, logger: loggers.get('api-key'), - isSecurityFeatureDisabled, + license, }); return { login: authenticator.login.bind(authenticator), diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/check_privileges.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.ts.snap rename to x-pack/plugins/security/server/authorization/__snapshots__/check_privileges.test.ts.snap diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/disable_ui_capabilities.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/disable_ui_capabilities.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/disable_ui_capabilities.test.ts.snap rename to x-pack/plugins/security/server/authorization/__snapshots__/disable_ui_capabilities.test.ts.snap diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/privilege_serializer.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/privilege_serializer.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/privilege_serializer.test.ts.snap rename to x-pack/plugins/security/server/authorization/__snapshots__/privilege_serializer.test.ts.snap diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/privileges_serializer.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/privileges_serializer.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/privileges_serializer.test.ts.snap rename to x-pack/plugins/security/server/authorization/__snapshots__/privileges_serializer.test.ts.snap diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/resource_serializer.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/resource_serializer.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/resource_serializer.test.ts.snap rename to x-pack/plugins/security/server/authorization/__snapshots__/resource_serializer.test.ts.snap diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/validate_es_response.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/__snapshots__/validate_es_response.test.ts.snap rename to x-pack/plugins/security/server/authorization/__snapshots__/validate_es_response.test.ts.snap diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/__snapshots__/api.test.ts.snap b/x-pack/plugins/security/server/authorization/actions/__snapshots__/api.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/__snapshots__/api.test.ts.snap rename to x-pack/plugins/security/server/authorization/actions/__snapshots__/api.test.ts.snap diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/__snapshots__/app.test.ts.snap b/x-pack/plugins/security/server/authorization/actions/__snapshots__/app.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/__snapshots__/app.test.ts.snap rename to x-pack/plugins/security/server/authorization/actions/__snapshots__/app.test.ts.snap diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/__snapshots__/saved_object.test.ts.snap b/x-pack/plugins/security/server/authorization/actions/__snapshots__/saved_object.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/__snapshots__/saved_object.test.ts.snap rename to x-pack/plugins/security/server/authorization/actions/__snapshots__/saved_object.test.ts.snap diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/__snapshots__/ui.test.ts.snap b/x-pack/plugins/security/server/authorization/actions/__snapshots__/ui.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/__snapshots__/ui.test.ts.snap rename to x-pack/plugins/security/server/authorization/actions/__snapshots__/ui.test.ts.snap diff --git a/x-pack/plugins/security/server/authorization/actions/actions.test.ts b/x-pack/plugins/security/server/authorization/actions/actions.test.ts new file mode 100644 index 0000000000000..384d25ca3b971 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/actions.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { Actions } from '.'; + +describe('#constructor', () => { + test(`doesn't allow an empty string`, () => { + expect(() => new Actions('')).toThrowErrorMatchingInlineSnapshot( + `"version can't be an empty string"` + ); + }); +}); + +describe('#login', () => { + test('returns login:', () => { + const actions = new Actions('mock-version'); + + expect(actions.login).toBe('login:'); + }); +}); + +describe('#version', () => { + test("returns `version:${config.get('pkg.version')}`", () => { + const version = 'mock-version'; + const actions = new Actions(version); + + expect(actions.version).toBe(`version:${version}`); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/actions.ts b/x-pack/plugins/security/server/authorization/actions/actions.ts similarity index 80% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/actions.ts rename to x-pack/plugins/security/server/authorization/actions/actions.ts index e10a0c9bc9313..4bf7a41550cc6 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/actions/actions.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.ts @@ -36,18 +36,9 @@ export class Actions { public readonly version = `version:${this.versionNumber}`; - constructor(private readonly versionNumber: string) {} -} - -export function actionsFactory(config: any) { - const version = config.get('pkg.version'); - if (typeof version !== 'string') { - throw new Error('version should be a string'); + constructor(private readonly versionNumber: string) { + if (versionNumber === '') { + throw new Error(`version can't be an empty string`); + } } - - if (version === '') { - throw new Error(`version can't be an empty string`); - } - - return new Actions(version); } diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/api.test.ts b/x-pack/plugins/security/server/authorization/actions/api.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/api.test.ts rename to x-pack/plugins/security/server/authorization/actions/api.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/api.ts b/x-pack/plugins/security/server/authorization/actions/api.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/api.ts rename to x-pack/plugins/security/server/authorization/actions/api.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/app.test.ts b/x-pack/plugins/security/server/authorization/actions/app.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/app.test.ts rename to x-pack/plugins/security/server/authorization/actions/app.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/app.ts b/x-pack/plugins/security/server/authorization/actions/app.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/app.ts rename to x-pack/plugins/security/server/authorization/actions/app.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/index.ts b/x-pack/plugins/security/server/authorization/actions/index.ts similarity index 82% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/index.ts rename to x-pack/plugins/security/server/authorization/actions/index.ts index 34af70cd479e3..d844ef5f4ae33 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/actions/index.ts +++ b/x-pack/plugins/security/server/authorization/actions/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { Actions, actionsFactory } from './actions'; +export { Actions } from './actions'; diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/saved_object.test.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/saved_object.test.ts rename to x-pack/plugins/security/server/authorization/actions/saved_object.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/saved_object.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/saved_object.ts rename to x-pack/plugins/security/server/authorization/actions/saved_object.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/space.test.ts b/x-pack/plugins/security/server/authorization/actions/space.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/space.test.ts rename to x-pack/plugins/security/server/authorization/actions/space.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/space.ts b/x-pack/plugins/security/server/authorization/actions/space.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/space.ts rename to x-pack/plugins/security/server/authorization/actions/space.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/ui.test.ts b/x-pack/plugins/security/server/authorization/actions/ui.test.ts similarity index 94% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/ui.test.ts rename to x-pack/plugins/security/server/authorization/actions/ui.test.ts index 7f486dc3a8c98..f91b7baf78baa 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/actions/ui.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/ui.test.ts @@ -29,10 +29,10 @@ describe('#allCatalogueEntries', () => { }); }); -describe('#allManagmentLinks', () => { +describe('#allManagementLinks', () => { test('returns `ui:${version}:management/*`', () => { const uiActions = new UIActions(version); - expect(uiActions.allManagmentLinks).toBe('ui:1.0.0-zeta1:management/*'); + expect(uiActions.allManagementLinks).toBe('ui:1.0.0-zeta1:management/*'); }); }); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/actions/ui.ts b/x-pack/plugins/security/server/authorization/actions/ui.ts similarity index 88% rename from x-pack/legacy/plugins/security/server/lib/authorization/actions/ui.ts rename to x-pack/plugins/security/server/authorization/actions/ui.ts index ec5af3496eae6..c243b4f0bbdc1 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/actions/ui.ts +++ b/x-pack/plugins/security/server/authorization/actions/ui.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { isString } from 'lodash'; -import { UICapabilities } from 'ui/capabilities'; -import { uiCapabilitiesRegex } from '../../../../../../../plugins/features/server'; +import { Capabilities as UICapabilities } from '../../../../../../src/core/public'; +import { uiCapabilitiesRegex } from '../../../../features/server'; export class UIActions { private readonly prefix: string; @@ -26,7 +26,7 @@ export class UIActions { return `${this.prefix}catalogue/*`; } - public get allManagmentLinks(): string { + public get allManagementLinks(): string { return `${this.prefix}management/*`; } diff --git a/x-pack/plugins/security/server/authorization/api_authorization.test.ts b/x-pack/plugins/security/server/authorization/api_authorization.test.ts new file mode 100644 index 0000000000000..a5902f251b082 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/api_authorization.test.ts @@ -0,0 +1,155 @@ +/* + * 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 { initAPIAuthorization } from './api_authorization'; + +import { + coreMock, + httpServerMock, + httpServiceMock, + loggingServiceMock, +} from '../../../../../src/core/server/mocks'; +import { authorizationMock } from './index.mock'; + +describe('initAPIAuthorization', () => { + test(`route that doesn't start with "/api/" continues`, async () => { + const mockHTTPSetup = coreMock.createSetup().http; + initAPIAuthorization( + mockHTTPSetup, + authorizationMock.create(), + loggingServiceMock.create().get() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ method: 'get', path: '/app/foo' }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + }); + + test(`protected route that starts with "/api/", but "mode.useRbacForRequest()" returns false continues`, async () => { + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create(); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/api/foo', + routeTags: ['access:foo'], + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + mockAuthz.mode.useRbacForRequest.mockReturnValue(false); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); + }); + + test(`unprotected route that starts with "/api/", but "mode.useRbacForRequest()" returns true continues`, async () => { + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create(); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/api/foo', + routeTags: ['not-access:foo'], + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); + }); + + test(`protected route that starts with "/api/", "mode.useRbacForRequest()" returns true and user is authorized continues`, async () => { + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/api/foo', + headers, + routeTags: ['access:foo'], + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: true }); + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + mockAuthz.checkPrivilegesDynamicallyWithRequest.mockImplementation(request => { + // hapi conceals the actual "request" from us, so we make sure that the headers are passed to + // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with + expect(request.headers).toMatchObject(headers); + + return mockCheckPrivileges; + }); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockAuthz.actions.api.get('foo')]); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); + }); + + test(`protected route that starts with "/api/", "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 404`, async () => { + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/api/foo', + headers, + routeTags: ['access:foo'], + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: false }); + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + mockAuthz.checkPrivilegesDynamicallyWithRequest.mockImplementation(request => { + // hapi conceals the actual "request" from us, so we make sure that the headers are passed to + // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with + expect(request.headers).toMatchObject(headers); + + return mockCheckPrivileges; + }); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).toHaveBeenCalledTimes(1); + expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockAuthz.actions.api.get('foo')]); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts similarity index 54% rename from x-pack/legacy/plugins/security/server/lib/authorization/api_authorization.ts rename to x-pack/plugins/security/server/authorization/api_authorization.ts index 57dd9a4802a5a..b280cc74c230f 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/api_authorization.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.ts @@ -4,26 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import { Request, ResponseToolkit, Server } from 'hapi'; -import { AuthorizationService } from './service'; - -export function initAPIAuthorization(server: Server, authorization: AuthorizationService) { - const { actions, checkPrivilegesDynamicallyWithRequest, mode } = authorization; - - server.ext('onPostAuth', async (request: Request, h: ResponseToolkit) => { +import { CoreSetup, Logger } from '../../../../../src/core/server'; +import { Authorization } from '.'; + +export function initAPIAuthorization( + http: CoreSetup['http'], + { actions, checkPrivilegesDynamicallyWithRequest, mode }: Authorization, + logger: Logger +) { + http.registerOnPostAuth(async (request, response, toolkit) => { // if the api doesn't start with "/api/" or we aren't using RBAC for this request, just continue - if (!request.path.startsWith('/api/') || !mode.useRbacForRequest(request)) { - return h.continue; + if (!request.url.path!.startsWith('/api/') || !mode.useRbacForRequest(request)) { + return toolkit.next(); } - const { tags = [] } = request.route.settings; + const tags = request.route.options.tags; const tagPrefix = 'access:'; const actionTags = tags.filter(tag => tag.startsWith(tagPrefix)); // if there are no tags starting with "access:", just continue if (actionTags.length === 0) { - return h.continue; + logger.debug('API endpoint is not marked with "access:" tags, skipping.'); + return toolkit.next(); } const apiActions = actionTags.map(tag => actions.api.get(tag.substring(tagPrefix.length))); @@ -32,9 +34,11 @@ export function initAPIAuthorization(server: Server, authorization: Authorizatio // we've actually authorized the request if (checkPrivilegesResponse.hasAllRequested) { - return h.continue; + logger.debug(`authorized for "${request.url.path}"`); + return toolkit.next(); } - return Boom.notFound(); + logger.debug(`not authorized for "${request.url.path}"`); + return response.notFound(); }); } diff --git a/x-pack/plugins/security/server/authorization/app_authorization.test.ts b/x-pack/plugins/security/server/authorization/app_authorization.test.ts new file mode 100644 index 0000000000000..6d23333022302 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/app_authorization.test.ts @@ -0,0 +1,175 @@ +/* + * 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 { PluginSetupContract as FeaturesSetupContract } from '../../../features/server'; +import { initAppAuthorization } from './app_authorization'; + +import { + loggingServiceMock, + coreMock, + httpServerMock, + httpServiceMock, +} from '../../../../../src/core/server/mocks'; +import { authorizationMock } from './index.mock'; + +const createFeaturesSetupContractMock = (): FeaturesSetupContract => { + return { + getFeatures: () => [{ id: 'foo', name: 'Foo', app: ['foo'], privileges: {} }], + } as FeaturesSetupContract; +}; + +describe('initAppAuthorization', () => { + test(`route that doesn't start with "/app/" continues`, async () => { + const mockHTTPSetup = coreMock.createSetup().http; + initAppAuthorization( + mockHTTPSetup, + authorizationMock.create(), + loggingServiceMock.create().get(), + createFeaturesSetupContractMock() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ method: 'get', path: '/api/foo' }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + }); + + test(`protected route that starts with "/app/", but "mode.useRbacForRequest()" returns false continues`, async () => { + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create(); + initAppAuthorization( + mockHTTPSetup, + mockAuthz, + loggingServiceMock.create().get(), + createFeaturesSetupContractMock() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ method: 'get', path: '/app/foo' }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + mockAuthz.mode.useRbacForRequest.mockReturnValue(false); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); + }); + + test(`unprotected route that starts with "/app/", and "mode.useRbacForRequest()" returns true continues`, async () => { + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create(); + initAppAuthorization( + mockHTTPSetup, + mockAuthz, + loggingServiceMock.create().get(), + createFeaturesSetupContractMock() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ method: 'get', path: '/app/bar' }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); + }); + + test(`protected route that starts with "/app/", "mode.useRbacForRequest()" returns true and user is authorized continues`, async () => { + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + + initAppAuthorization( + mockHTTPSetup, + mockAuthz, + loggingServiceMock.create().get(), + createFeaturesSetupContractMock() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/app/foo', + headers, + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: true }); + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + mockAuthz.checkPrivilegesDynamicallyWithRequest.mockImplementation(request => { + // hapi conceals the actual "request" from us, so we make sure that the headers are passed to + // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with + expect(request.headers).toMatchObject(headers); + + return mockCheckPrivileges; + }); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges).toHaveBeenCalledWith(mockAuthz.actions.app.get('foo')); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); + }); + + test(`protected route that starts with "/app/", "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 404`, async () => { + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + + initAppAuthorization( + mockHTTPSetup, + mockAuthz, + loggingServiceMock.create().get(), + createFeaturesSetupContractMock() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/app/foo', + headers, + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: false }); + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + mockAuthz.checkPrivilegesDynamicallyWithRequest.mockImplementation(request => { + // hapi conceals the actual "request" from us, so we make sure that the headers are passed to + // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with + expect(request.headers).toMatchObject(headers); + + return mockCheckPrivileges; + }); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).toHaveBeenCalledTimes(1); + expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); + expect(mockCheckPrivileges).toHaveBeenCalledWith(mockAuthz.actions.app.get('foo')); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/app_authorization.ts b/x-pack/plugins/security/server/authorization/app_authorization.ts similarity index 51% rename from x-pack/legacy/plugins/security/server/lib/authorization/app_authorization.ts rename to x-pack/plugins/security/server/authorization/app_authorization.ts index dd44050ec3e2a..8516e8228ab5a 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/app_authorization.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.ts @@ -4,22 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import { Request, ResponseToolkit, Server } from 'hapi'; -import { flatten } from 'lodash'; -import { XPackMainPlugin } from '../../../../xpack_main/xpack_main'; -import { AuthorizationService } from './service'; +import { CoreSetup, Logger } from '../../../../../src/core/server'; +import { FeaturesService } from '../plugin'; +import { Authorization } from '.'; + class ProtectedApplications { private applications: Set | null = null; - constructor(private readonly xpackMainPlugin: XPackMainPlugin) {} + constructor(private readonly featuresService: FeaturesService) {} public shouldProtect(appId: string) { // Currently, once we get the list of features we essentially "lock" additional - // features from being added. This is enforced by the xpackMain plugin. As such, + // features from being added. This is enforced by the Features plugin. As such, // we wait until we actually need to consume these before getting them if (this.applications == null) { this.applications = new Set( - flatten(this.xpackMainPlugin.getFeatures().map(feature => feature.app)) + this.featuresService + .getFeatures() + .map(feature => feature.app) + .flat() ); } @@ -28,45 +30,49 @@ class ProtectedApplications { } export function initAppAuthorization( - server: Server, - xpackMainPlugin: XPackMainPlugin, - authorization: AuthorizationService + http: CoreSetup['http'], + { + actions, + checkPrivilegesDynamicallyWithRequest, + mode, + }: Pick, + logger: Logger, + featuresService: FeaturesService ) { - const { actions, checkPrivilegesDynamicallyWithRequest, mode } = authorization; - const protectedApplications = new ProtectedApplications(xpackMainPlugin); - const log = (msg: string) => server.log(['security', 'app-authorization', 'debug'], msg); + const protectedApplications = new ProtectedApplications(featuresService); + + http.registerOnPostAuth(async (request, response, toolkit) => { + const path = request.url.pathname!; - server.ext('onPostAuth', async (request: Request, h: ResponseToolkit) => { - const { path } = request; // if the path doesn't start with "/app/", just continue if (!path.startsWith('/app/')) { - return h.continue; + return toolkit.next(); } // if we aren't using RBAC, just continue if (!mode.useRbacForRequest(request)) { - return h.continue; + return toolkit.next(); } const appId = path.split('/', 3)[2]; if (!protectedApplications.shouldProtect(appId)) { - log(`not authorizing - "${appId}" isn't a protected application`); - return h.continue; + logger.debug(`not authorizing - "${appId}" isn't a protected application`); + return toolkit.next(); } const checkPrivileges = checkPrivilegesDynamicallyWithRequest(request); const appAction = actions.app.get(appId); const checkPrivilegesResponse = await checkPrivileges(appAction); - log(`authorizing access to "${appId}"`); + logger.debug(`authorizing access to "${appId}"`); // we've actually authorized the request if (checkPrivilegesResponse.hasAllRequested) { - log(`authorized for "${appId}"`); - return h.continue; + logger.debug(`authorized for "${appId}"`); + return toolkit.next(); } - log(`not authorized for "${appId}"`); - return Boom.notFound(); + logger.debug(`not authorized for "${appId}"`); + return response.notFound(); }); } diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_privileges.test.ts similarity index 94% rename from x-pack/legacy/plugins/security/server/lib/authorization/check_privileges.test.ts rename to x-pack/plugins/security/server/authorization/check_privileges.test.ts index b418e02474f4a..b1cb78008da00 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -5,10 +5,12 @@ */ import { uniq } from 'lodash'; -import { GLOBAL_RESOURCE } from '../../../common/constants'; +import { GLOBAL_RESOURCE } from '../../common/constants'; import { checkPrivilegesWithRequestFactory } from './check_privileges'; import { HasPrivilegesResponse } from './types'; +import { elasticsearchServiceMock, httpServerMock } from '../../../../../src/core/server/mocks'; + const application = 'kibana-our_application'; const mockActions = { @@ -18,14 +20,14 @@ const mockActions = { const savedObjectTypes = ['foo-type', 'bar-type']; -const createMockShieldClient = (response: any) => { - const mockCallWithRequest = jest.fn(); +const createMockClusterClient = (response: any) => { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(response); - mockCallWithRequest.mockImplementationOnce(async () => response); + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); - return { - callWithRequest: mockCallWithRequest, - }; + return { mockClusterClient, mockScopedClusterClient }; }; describe('#atSpace', () => { @@ -40,13 +42,15 @@ describe('#atSpace', () => { } ) => { test(description, async () => { - const mockShieldClient = createMockShieldClient(options.esHasPrivilegesResponse); + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( mockActions, - application, - mockShieldClient + mockClusterClient, + () => application ); - const request = { foo: Symbol() }; + const request = httpServerMock.createKibanaRequest(); const checkPrivileges = checkPrivilegesWithRequest(request); let actualResult; @@ -60,8 +64,8 @@ describe('#atSpace', () => { errorThrown = err; } - expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith( - request, + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( 'shield.hasPrivileges', { body: { @@ -281,13 +285,15 @@ describe('#atSpaces', () => { } ) => { test(description, async () => { - const mockShieldClient = createMockShieldClient(options.esHasPrivilegesResponse); + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( mockActions, - application, - mockShieldClient + mockClusterClient, + () => application ); - const request = { foo: Symbol() }; + const request = httpServerMock.createKibanaRequest(); const checkPrivileges = checkPrivilegesWithRequest(request); let actualResult; @@ -301,8 +307,8 @@ describe('#atSpaces', () => { errorThrown = err; } - expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith( - request, + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( 'shield.hasPrivileges', { body: { @@ -760,13 +766,15 @@ describe('#globally', () => { } ) => { test(description, async () => { - const mockShieldClient = createMockShieldClient(options.esHasPrivilegesResponse); + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( mockActions, - application, - mockShieldClient + mockClusterClient, + () => application ); - const request = { foo: Symbol() }; + const request = httpServerMock.createKibanaRequest(); const checkPrivileges = checkPrivilegesWithRequest(request); let actualResult; @@ -777,8 +785,8 @@ describe('#globally', () => { errorThrown = err; } - expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith( - request, + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( 'shield.hasPrivileges', { body: { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges.ts b/x-pack/plugins/security/server/authorization/check_privileges.ts similarity index 85% rename from x-pack/legacy/plugins/security/server/lib/authorization/check_privileges.ts rename to x-pack/plugins/security/server/authorization/check_privileges.ts index a23f89a4bd7a5..5bc3ce075452d 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.ts @@ -5,7 +5,8 @@ */ import { pick, transform, uniq } from 'lodash'; -import { GLOBAL_RESOURCE } from '../../../common/constants'; +import { IClusterClient, KibanaRequest } from '../../../../../src/core/server'; +import { GLOBAL_RESOURCE } from '../../common/constants'; import { ResourceSerializer } from './resource_serializer'; import { HasPrivilegesResponse, HasPrivilegesResponseApplication } from './types'; import { validateEsPrivilegeResponse } from './validate_es_response'; @@ -43,7 +44,7 @@ export interface CheckPrivilegesAtSpacesResponse { }; } -export type CheckPrivilegesWithRequest = (request: Record) => CheckPrivileges; +export type CheckPrivilegesWithRequest = (request: KibanaRequest) => CheckPrivileges; export interface CheckPrivileges { atSpace( @@ -59,12 +60,10 @@ export interface CheckPrivileges { export function checkPrivilegesWithRequestFactory( actions: CheckPrivilegesActions, - application: string, - shieldClient: any + clusterClient: IClusterClient, + getApplicationName: () => string ) { - const { callWithRequest } = shieldClient; - - const hasIncompatibileVersion = ( + const hasIncompatibleVersion = ( applicationPrivilegesResponse: HasPrivilegesResponseApplication ) => { return Object.values(applicationPrivilegesResponse).some( @@ -72,7 +71,7 @@ export function checkPrivilegesWithRequestFactory( ); }; - return function checkPrivilegesWithRequest(request: Record): CheckPrivileges { + return function checkPrivilegesWithRequest(request: KibanaRequest): CheckPrivileges { const checkPrivilegesAtResources = async ( resources: string[], privilegeOrPrivileges: string | string[] @@ -82,21 +81,14 @@ export function checkPrivilegesWithRequestFactory( : [privilegeOrPrivileges]; const allApplicationPrivileges = uniq([actions.version, actions.login, ...privileges]); - const hasPrivilegesResponse: HasPrivilegesResponse = await callWithRequest( - request, - 'shield.hasPrivileges', - { + const application = getApplicationName(); + const hasPrivilegesResponse = (await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.hasPrivileges', { body: { - applications: [ - { - application, - resources, - privileges: allApplicationPrivileges, - }, - ], + applications: [{ application, resources, privileges: allApplicationPrivileges }], }, - } - ); + })) as HasPrivilegesResponse; validateEsPrivilegeResponse( hasPrivilegesResponse, @@ -107,7 +99,7 @@ export function checkPrivilegesWithRequestFactory( const applicationPrivilegesResponse = hasPrivilegesResponse.application[application]; - if (hasIncompatibileVersion(applicationPrivilegesResponse)) { + if (hasIncompatibleVersion(applicationPrivilegesResponse)) { throw new Error( 'Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.' ); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.test.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts similarity index 73% rename from x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.test.ts rename to x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts index 6df9d6801e2dc..2206748597635 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacySpacesPlugin } from '../../../../spaces'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; +import { httpServerMock } from '../../../../../src/core/server/mocks'; + test(`checkPrivileges.atSpace when spaces is enabled`, async () => { const expectedResult = Symbol(); const spaceId = 'foo-space'; @@ -15,21 +15,15 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { atSpace: jest.fn().mockReturnValue(expectedResult), }; const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockSpaces = { - isEnabled: true, - getSpaceId: jest.fn().mockReturnValue(spaceId), - spaceIdToNamespace: jest.fn(), - namespaceToSpaceId: jest.fn(), - getBasePath: jest.fn(), - getScopedSpacesClient: jest.fn(), - getActiveSpace: jest.fn(), - } as OptionalPlugin; - const request = Symbol(); + const request = httpServerMock.createKibanaRequest(); const privilegeOrPrivileges = ['foo', 'bar']; const checkPrivilegesDynamically = checkPrivilegesDynamicallyWithRequestFactory( mockCheckPrivilegesWithRequest, - mockSpaces - )(request as any); + () => ({ + getSpaceId: jest.fn().mockReturnValue(spaceId), + namespaceToSpaceId: jest.fn(), + }) + )(request); const result = await checkPrivilegesDynamically(privilegeOrPrivileges); expect(result).toBe(expectedResult); @@ -43,15 +37,12 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { globally: jest.fn().mockReturnValue(expectedResult), }; const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockSpaces = { - isEnabled: false, - } as OptionalPlugin; - const request = Symbol(); + const request = httpServerMock.createKibanaRequest(); const privilegeOrPrivileges = ['foo', 'bar']; const checkPrivilegesDynamically = checkPrivilegesDynamicallyWithRequestFactory( mockCheckPrivilegesWithRequest, - mockSpaces - )(request as any); + () => undefined + )(request); const result = await checkPrivilegesDynamically(privilegeOrPrivileges); expect(result).toBe(expectedResult); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts similarity index 56% rename from x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.ts rename to x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts index 243ad100c5715..0377dd06eb669 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/check_privileges_dynamically.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts @@ -4,39 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; +import { KibanaRequest } from '../../../../../src/core/server'; +import { SpacesService } from '../plugin'; import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from './check_privileges'; -/* - * 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 { LegacySpacesPlugin } from '../../../../spaces'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; - export type CheckPrivilegesDynamically = ( privilegeOrPrivileges: string | string[] ) => Promise; export type CheckPrivilegesDynamicallyWithRequest = ( - request: Legacy.Request + request: KibanaRequest ) => CheckPrivilegesDynamically; export function checkPrivilegesDynamicallyWithRequestFactory( checkPrivilegesWithRequest: CheckPrivilegesWithRequest, - spaces: OptionalPlugin + getSpacesService: () => SpacesService | undefined ): CheckPrivilegesDynamicallyWithRequest { - return function checkPrivilegesDynamicallyWithRequest(request: Legacy.Request) { + return function checkPrivilegesDynamicallyWithRequest(request: KibanaRequest) { const checkPrivileges = checkPrivilegesWithRequest(request); return async function checkPrivilegesDynamically(privilegeOrPrivileges: string | string[]) { - if (spaces.isEnabled) { - const spaceId = spaces.getSpaceId(request); - return await checkPrivileges.atSpace(spaceId, privilegeOrPrivileges); - } else { - return await checkPrivileges.globally(privilegeOrPrivileges); - } + const spacesService = getSpacesService(); + return spacesService + ? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privilegeOrPrivileges) + : await checkPrivileges.globally(privilegeOrPrivileges); }; }; } diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts similarity index 73% rename from x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.test.ts rename to x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts index 7fa02330fac97..4618e8e6641fc 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacySpacesPlugin } from '../../../../spaces'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; +import { httpServerMock } from '../../../../../src/core/server/mocks'; + test(`checkPrivileges.atSpace when spaces is enabled`, async () => { const expectedResult = Symbol(); const spaceId = 'foo-space'; @@ -15,19 +15,17 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { atSpace: jest.fn().mockReturnValue(expectedResult), }; const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - - const mockSpaces = ({ - isEnabled: true, - namespaceToSpaceId: jest.fn().mockReturnValue(spaceId), - } as unknown) as OptionalPlugin; - const request = Symbol(); - + const request = httpServerMock.createKibanaRequest(); const privilegeOrPrivileges = ['foo', 'bar']; + const mockSpacesService = { + getSpaceId: jest.fn(), + namespaceToSpaceId: jest.fn().mockReturnValue(spaceId), + }; const checkSavedObjectsPrivileges = checkSavedObjectsPrivilegesWithRequestFactory( mockCheckPrivilegesWithRequest, - mockSpaces - )(request as any); + () => mockSpacesService + )(request); const namespace = 'foo'; @@ -36,7 +34,7 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, privilegeOrPrivileges); - expect(mockSpaces.namespaceToSpaceId).toBeCalledWith(namespace); + expect(mockSpacesService.namespaceToSpaceId).toBeCalledWith(namespace); }); test(`checkPrivileges.globally when spaces is disabled`, async () => { @@ -45,21 +43,15 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { globally: jest.fn().mockReturnValue(expectedResult), }; const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockSpaces = ({ - isEnabled: false, - namespaceToSpaceId: jest.fn().mockImplementation(() => { - throw new Error('should not be called'); - }), - } as unknown) as OptionalPlugin; - const request = Symbol(); + const request = httpServerMock.createKibanaRequest(); const privilegeOrPrivileges = ['foo', 'bar']; const checkSavedObjectsPrivileges = checkSavedObjectsPrivilegesWithRequestFactory( mockCheckPrivilegesWithRequest, - mockSpaces - )(request as any); + () => undefined + )(request); const namespace = 'foo'; @@ -68,5 +60,4 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(privilegeOrPrivileges); - expect(mockSpaces.namespaceToSpaceId).not.toHaveBeenCalled(); }); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts similarity index 65% rename from x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.ts rename to x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts index fb1d258b5a05f..02958fe265efa 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/check_saved_objects_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts @@ -4,13 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; -import { LegacySpacesPlugin } from '../../../../spaces'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; +import { KibanaRequest } from '../../../../../src/core/server'; +import { SpacesService } from '../plugin'; import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from './check_privileges'; export type CheckSavedObjectsPrivilegesWithRequest = ( - request: Legacy.Request + request: KibanaRequest ) => CheckSavedObjectsPrivileges; export type CheckSavedObjectsPrivileges = ( actions: string | string[], @@ -19,20 +18,20 @@ export type CheckSavedObjectsPrivileges = ( export const checkSavedObjectsPrivilegesWithRequestFactory = ( checkPrivilegesWithRequest: CheckPrivilegesWithRequest, - spaces: OptionalPlugin + getSpacesService: () => SpacesService | undefined ): CheckSavedObjectsPrivilegesWithRequest => { - return function checkSavedObjectsPrivilegesWithRequest(request: Legacy.Request) { + return function checkSavedObjectsPrivilegesWithRequest(request: KibanaRequest) { return async function checkSavedObjectsPrivileges( actions: string | string[], namespace?: string ) { - if (spaces.isEnabled) { - return checkPrivilegesWithRequest(request).atSpace( - spaces.namespaceToSpaceId(namespace), - actions - ); - } - return checkPrivilegesWithRequest(request).globally(actions); + const spacesService = getSpacesService(); + return spacesService + ? await checkPrivilegesWithRequest(request).atSpace( + spacesService.namespaceToSpaceId(namespace), + actions + ) + : await checkPrivilegesWithRequest(request).globally(actions); }; }; }; diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts similarity index 54% rename from x-pack/legacy/plugins/security/server/lib/authorization/disable_ui_capabilities.test.ts rename to x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index 198a36177c55a..49c9db2d0e6e3 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -5,79 +5,48 @@ */ import { Actions } from '.'; -import { Feature } from '../../../../../../plugins/features/server'; -import { disableUICapabilitesFactory } from './disable_ui_capabilities'; +import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; -interface MockServerOptions { - checkPrivileges: { - reject?: any; - resolve?: any; - }; - features: Feature[]; -} +import { httpServerMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { authorizationMock } from './index.mock'; -const actions = new Actions('1.0.0-zeta1'); -const mockRequest = { - foo: Symbol(), -}; +type MockAuthzOptions = { rejectCheckPrivileges: any } | { resolveCheckPrivileges: any }; -const createMockServer = (options: MockServerOptions) => { - const mockAuthorizationService = { - actions, - checkPrivilegesDynamicallyWithRequest(request: any) { - expect(request).toBe(mockRequest); - - return jest.fn().mockImplementation(checkActions => { - if (options.checkPrivileges.reject) { - throw options.checkPrivileges.reject; - } - - if (options.checkPrivileges.resolve) { - expect(checkActions).toEqual(Object.keys(options.checkPrivileges.resolve.privileges)); - return options.checkPrivileges.resolve; - } +const actions = new Actions('1.0.0-zeta1'); +const mockRequest = httpServerMock.createKibanaRequest(); - throw new Error('resolve or reject should have been provided'); - }); - }, - }; +const createMockAuthz = (options: MockAuthzOptions) => { + const mock = authorizationMock.create({ version: '1.0.0-zeta1' }); + mock.checkPrivilegesDynamicallyWithRequest.mockImplementation(request => { + expect(request).toBe(mockRequest); - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(options.features), - }; + return jest.fn().mockImplementation(checkActions => { + if ('rejectCheckPrivileges' in options) { + throw options.rejectCheckPrivileges; + } - return { - log: jest.fn(), - plugins: { - security: { - authorization: mockAuthorizationService, - }, - xpack_main: mockXPackMainPlugin, - }, - }; + expect(checkActions).toEqual(Object.keys(options.resolveCheckPrivileges.privileges)); + return options.resolveCheckPrivileges; + }); + }); + return mock; }; describe('usingPrivileges', () => { describe('checkPrivileges errors', () => { test(`disables uiCapabilities when a 401 is thrown`, async () => { - const mockServer = createMockServer({ - checkPrivileges: { - reject: { - statusCode: 401, - message: 'super informative message', - }, - }, - features: [ - { - id: 'fooFeature', - name: 'Foo Feature', - app: [], - navLinkId: 'foo', - privileges: {}, - }, - ], + const mockAuthz = createMockAuthz({ + rejectCheckPrivileges: { statusCode: 401, message: 'super informative message' }, }); - const { usingPrivileges } = disableUICapabilitesFactory(mockServer, mockRequest); + const mockLoggers = loggingServiceMock.create(); + + const { usingPrivileges } = disableUICapabilitiesFactory( + mockRequest, + [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + mockLoggers.get(), + mockAuthz + ); + const result = await usingPrivileges( Object.freeze({ navLinks: { @@ -122,46 +91,28 @@ describe('usingPrivileges', () => { }, }); - expect(mockServer.log).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - "security", - "debug", - ], - "Disabling all uiCapabilities because we received a 401: super informative message", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], -} -`); + expect(loggingServiceMock.collect(mockLoggers).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "Disabling all uiCapabilities because we received a 401: super informative message", + ], + ] + `); }); test(`disables uiCapabilities when a 403 is thrown`, async () => { - const mockServer = createMockServer({ - checkPrivileges: { - reject: { - statusCode: 403, - message: 'even more super informative message', - }, - }, - features: [ - { - id: 'fooFeature', - name: 'Foo Feature', - navLinkId: 'foo', - app: [], - privileges: {}, - }, - ], + const mockAuthz = createMockAuthz({ + rejectCheckPrivileges: { statusCode: 403, message: 'even more super informative message' }, }); - const { usingPrivileges } = disableUICapabilitesFactory(mockServer, mockRequest); + const mockLoggers = loggingServiceMock.create(); + + const { usingPrivileges } = disableUICapabilitiesFactory( + mockRequest, + [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + mockLoggers.get(), + mockAuthz + ); + const result = await usingPrivileges( Object.freeze({ navLinks: { @@ -205,35 +156,28 @@ describe('usingPrivileges', () => { bar: false, }, }); - expect(mockServer.log).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - "security", - "debug", - ], - "Disabling all uiCapabilities because we received a 403: even more super informative message", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], -} -`); + expect(loggingServiceMock.collect(mockLoggers).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "Disabling all uiCapabilities because we received a 403: even more super informative message", + ], + ] + `); }); test(`otherwise it throws the error`, async () => { - const mockServer = createMockServer({ - checkPrivileges: { - reject: new Error('something else entirely'), - }, - features: [], + const mockAuthz = createMockAuthz({ + rejectCheckPrivileges: new Error('something else entirely'), }); - const { usingPrivileges } = disableUICapabilitesFactory(mockServer, mockRequest); + const mockLoggers = loggingServiceMock.create(); + + const { usingPrivileges } = disableUICapabilitiesFactory( + mockRequest, + [], + mockLoggers.get(), + mockAuthz + ); + await expect( usingPrivileges({ navLinks: { @@ -248,28 +192,40 @@ describe('usingPrivileges', () => { catalogue: {}, }) ).rejects.toThrowErrorMatchingSnapshot(); - expect(mockServer.log).not.toHaveBeenCalled(); + expect(loggingServiceMock.collect(mockLoggers)).toMatchInlineSnapshot(` + Object { + "debug": Array [], + "error": Array [], + "fatal": Array [], + "info": Array [], + "log": Array [], + "trace": Array [], + "warn": Array [], + } + `); }); }); test(`disables ui capabilities when they don't have privileges`, async () => { - const mockServer = createMockServer({ - checkPrivileges: { - resolve: { - privileges: { - [actions.ui.get('navLinks', 'foo')]: true, - [actions.ui.get('navLinks', 'bar')]: false, - [actions.ui.get('navLinks', 'quz')]: false, - [actions.ui.get('management', 'kibana', 'indices')]: true, - [actions.ui.get('management', 'kibana', 'settings')]: false, - [actions.ui.get('fooFeature', 'foo')]: true, - [actions.ui.get('fooFeature', 'bar')]: false, - [actions.ui.get('barFeature', 'foo')]: true, - [actions.ui.get('barFeature', 'bar')]: false, - }, + const mockAuthz = createMockAuthz({ + resolveCheckPrivileges: { + privileges: { + [actions.ui.get('navLinks', 'foo')]: true, + [actions.ui.get('navLinks', 'bar')]: false, + [actions.ui.get('navLinks', 'quz')]: false, + [actions.ui.get('management', 'kibana', 'indices')]: true, + [actions.ui.get('management', 'kibana', 'settings')]: false, + [actions.ui.get('fooFeature', 'foo')]: true, + [actions.ui.get('fooFeature', 'bar')]: false, + [actions.ui.get('barFeature', 'foo')]: true, + [actions.ui.get('barFeature', 'bar')]: false, }, }, - features: [ + }); + + const { usingPrivileges } = disableUICapabilitiesFactory( + mockRequest, + [ { id: 'fooFeature', name: 'Foo Feature', @@ -285,8 +241,10 @@ describe('usingPrivileges', () => { privileges: {}, }, ], - }); - const { usingPrivileges } = disableUICapabilitesFactory(mockServer, mockRequest); + loggingServiceMock.create().get(), + mockAuthz + ); + const result = await usingPrivileges( Object.freeze({ navLinks: { @@ -337,21 +295,23 @@ describe('usingPrivileges', () => { }); test(`doesn't re-enable disabled uiCapabilities`, async () => { - const mockServer = createMockServer({ - checkPrivileges: { - resolve: { - privileges: { - [actions.ui.get('navLinks', 'foo')]: true, - [actions.ui.get('navLinks', 'bar')]: true, - [actions.ui.get('management', 'kibana', 'indices')]: true, - [actions.ui.get('fooFeature', 'foo')]: true, - [actions.ui.get('fooFeature', 'bar')]: true, - [actions.ui.get('barFeature', 'foo')]: true, - [actions.ui.get('barFeature', 'bar')]: true, - }, + const mockAuthz = createMockAuthz({ + resolveCheckPrivileges: { + privileges: { + [actions.ui.get('navLinks', 'foo')]: true, + [actions.ui.get('navLinks', 'bar')]: true, + [actions.ui.get('management', 'kibana', 'indices')]: true, + [actions.ui.get('fooFeature', 'foo')]: true, + [actions.ui.get('fooFeature', 'bar')]: true, + [actions.ui.get('barFeature', 'foo')]: true, + [actions.ui.get('barFeature', 'bar')]: true, }, }, - features: [ + }); + + const { usingPrivileges } = disableUICapabilitiesFactory( + mockRequest, + [ { id: 'fooFeature', name: 'Foo Feature', @@ -367,8 +327,10 @@ describe('usingPrivileges', () => { privileges: {}, }, ], - }); - const { usingPrivileges } = disableUICapabilitesFactory(mockServer, mockRequest); + loggingServiceMock.create().get(), + mockAuthz + ); + const result = await usingPrivileges( Object.freeze({ navLinks: { @@ -417,21 +379,15 @@ describe('usingPrivileges', () => { describe('all', () => { test(`disables uiCapabilities`, () => { - const mockServer = createMockServer({ - checkPrivileges: { - reject: new Error(`Don't use me`), - }, - features: [ - { - id: 'fooFeature', - name: 'Foo Feature', - navLinkId: 'foo', - app: [], - privileges: {}, - }, - ], - }); - const { all } = disableUICapabilitesFactory(mockServer, mockRequest); + const mockAuthz = createMockAuthz({ rejectCheckPrivileges: new Error(`Don't use me`) }); + + const { all } = disableUICapabilitiesFactory( + mockRequest, + [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + loggingServiceMock.create().get(), + mockAuthz + ); + const result = all( Object.freeze({ navLinks: { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/disable_ui_capabilities.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts similarity index 81% rename from x-pack/legacy/plugins/security/server/lib/authorization/disable_ui_capabilities.ts rename to x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts index 4d952bca20a3d..be26f52fbf756 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/disable_ui_capabilities.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts @@ -6,26 +6,22 @@ import { flatten, isObject, mapValues } from 'lodash'; import { UICapabilities } from 'ui/capabilities'; -import { Feature } from '../../../../../../plugins/features/server'; -import { Actions } from './actions'; +import { KibanaRequest, Logger } from '../../../../../src/core/server'; +import { Feature } from '../../../features/server'; + import { CheckPrivilegesAtResourceResponse } from './check_privileges'; -import { CheckPrivilegesDynamically } from './check_privileges_dynamically'; +import { Authorization } from './index'; -export function disableUICapabilitesFactory( - server: Record, - request: Record +export function disableUICapabilitiesFactory( + request: KibanaRequest, + features: Feature[], + logger: Logger, + authz: Authorization ) { - const { - security: { authorization }, - xpack_main: xpackMainPlugin, - } = server.plugins; - - const features: Feature[] = xpackMainPlugin.getFeatures(); const featureNavLinkIds = features .map(feature => feature.navLinkId) .filter(navLinkId => navLinkId != null); - const actions: Actions = authorization.actions; const shouldDisableFeatureUICapability = ( featureId: keyof UICapabilities, uiCapability: string @@ -61,10 +57,10 @@ export function disableUICapabilitesFactory( value: boolean | Record ): string[] { if (typeof value === 'boolean') { - return [actions.ui.get(featureId, uiCapability)]; + return [authz.actions.ui.get(featureId, uiCapability)]; } if (isObject(value)) { - return Object.keys(value).map(item => actions.ui.get(featureId, uiCapability, item)); + return Object.keys(value).map(item => authz.actions.ui.get(featureId, uiCapability, item)); } throw new Error(`Expected value type of boolean or object, but found ${value}`); } @@ -83,17 +79,14 @@ export function disableUICapabilitesFactory( let checkPrivilegesResponse: CheckPrivilegesAtResourceResponse; try { - const checkPrivilegesDynamically: CheckPrivilegesDynamically = authorization.checkPrivilegesDynamicallyWithRequest( - request - ); + const checkPrivilegesDynamically = authz.checkPrivilegesDynamicallyWithRequest(request); checkPrivilegesResponse = await checkPrivilegesDynamically(uiActions); } catch (err) { // if we get a 401/403, then we want to disable all uiCapabilities, as this // is generally when the user hasn't authenticated yet and we're displaying the // login screen, which isn't driven any uiCapabilities if (err.statusCode === 401 || err.statusCode === 403) { - server.log( - ['security', 'debug'], + logger.debug( `Disabling all uiCapabilities because we received a ${err.statusCode}: ${err.message}` ); return disableAll(uiCapabilities); @@ -107,11 +100,11 @@ export function disableUICapabilitesFactory( ...uiCapabilityParts: string[] ) => { // if the uiCapability has already been disabled, we don't want to re-enable it - if (enabled === false) { + if (!enabled) { return false; } - const action = actions.ui.get(featureId, ...uiCapabilityParts); + const action = authz.actions.ui.get(featureId, ...uiCapabilityParts); return checkPrivilegesResponse.privileges[action] === true; }; diff --git a/x-pack/plugins/security/server/authorization/index.mock.ts b/x-pack/plugins/security/server/authorization/index.mock.ts new file mode 100644 index 0000000000000..2e700745c69dc --- /dev/null +++ b/x-pack/plugins/security/server/authorization/index.mock.ts @@ -0,0 +1,22 @@ +/* + * 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 { Actions } from '.'; +import { AuthorizationMode } from './mode'; + +export const authorizationMock = { + create: ({ version = 'mock-version' }: { version?: string } = {}) => ({ + actions: new Actions(version), + checkPrivilegesWithRequest: jest.fn(), + checkPrivilegesDynamicallyWithRequest: jest.fn(), + checkSavedObjectsPrivilegesWithRequest: jest.fn(), + getApplicationName: jest.fn().mockReturnValue('mock-application'), + mode: { useRbacForRequest: jest.fn() } as jest.Mocked, + privileges: { get: jest.fn() }, + registerPrivilegesWithCluster: jest.fn(), + disableUnauthorizedCapabilities: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/server/authorization/index.test.ts b/x-pack/plugins/security/server/authorization/index.test.ts new file mode 100644 index 0000000000000..24179e062230a --- /dev/null +++ b/x-pack/plugins/security/server/authorization/index.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { + mockAuthorizationModeFactory, + mockCheckPrivilegesDynamicallyWithRequestFactory, + mockCheckPrivilegesWithRequestFactory, + mockCheckSavedObjectsPrivilegesWithRequestFactory, + mockPrivilegesFactory, +} from './service.test.mocks'; + +import { checkPrivilegesWithRequestFactory } from './check_privileges'; +import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; +import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; +import { authorizationModeFactory } from './mode'; +import { privilegesFactory } from './privileges'; +import { setupAuthorization } from '.'; + +import { + coreMock, + elasticsearchServiceMock, + loggingServiceMock, +} from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../licensing/index.mock'; + +test(`returns exposed services`, () => { + const kibanaIndexName = '.a-kibana-index'; + const application = `kibana-${kibanaIndexName}`; + + const mockCheckPrivilegesWithRequest = Symbol(); + mockCheckPrivilegesWithRequestFactory.mockReturnValue(mockCheckPrivilegesWithRequest); + + const mockCheckPrivilegesDynamicallyWithRequest = Symbol(); + mockCheckPrivilegesDynamicallyWithRequestFactory.mockReturnValue( + mockCheckPrivilegesDynamicallyWithRequest + ); + + const mockCheckSavedObjectsPrivilegesWithRequest = Symbol(); + mockCheckSavedObjectsPrivilegesWithRequestFactory.mockReturnValue( + mockCheckSavedObjectsPrivilegesWithRequest + ); + + const mockPrivilegesService = Symbol(); + mockPrivilegesFactory.mockReturnValue(mockPrivilegesService); + const mockAuthorizationMode = Symbol(); + mockAuthorizationModeFactory.mockReturnValue(mockAuthorizationMode); + + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + const mockGetSpacesService = jest + .fn() + .mockReturnValue({ getSpaceId: jest.fn(), namespaceToSpaceId: jest.fn() }); + const mockFeaturesService = { getFeatures: () => [] }; + const mockGetLegacyAPI = () => ({ kibanaIndexName }); + const mockLicense = licenseMock.create(); + + const authz = setupAuthorization({ + http: coreMock.createSetup().http, + clusterClient: mockClusterClient, + license: mockLicense, + loggers: loggingServiceMock.create(), + getLegacyAPI: mockGetLegacyAPI, + packageVersion: 'some-version', + featuresService: mockFeaturesService, + getSpacesService: mockGetSpacesService, + }); + + expect(authz.actions.version).toBe('version:some-version'); + expect(authz.getApplicationName()).toBe(application); + + expect(authz.checkPrivilegesWithRequest).toBe(mockCheckPrivilegesWithRequest); + expect(checkPrivilegesWithRequestFactory).toHaveBeenCalledWith( + authz.actions, + mockClusterClient, + authz.getApplicationName + ); + + expect(authz.checkPrivilegesDynamicallyWithRequest).toBe( + mockCheckPrivilegesDynamicallyWithRequest + ); + expect(checkPrivilegesDynamicallyWithRequestFactory).toHaveBeenCalledWith( + mockCheckPrivilegesWithRequest, + mockGetSpacesService + ); + + expect(authz.checkSavedObjectsPrivilegesWithRequest).toBe( + mockCheckSavedObjectsPrivilegesWithRequest + ); + expect(checkSavedObjectsPrivilegesWithRequestFactory).toHaveBeenCalledWith( + mockCheckPrivilegesWithRequest, + mockGetSpacesService + ); + + expect(authz.privileges).toBe(mockPrivilegesService); + expect(privilegesFactory).toHaveBeenCalledWith(authz.actions, mockFeaturesService); + + 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 new file mode 100644 index 0000000000000..b5f9efadbd8d0 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/index.ts @@ -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 { UICapabilities } from 'ui/capabilities'; +import { + CoreSetup, + LoggerFactory, + KibanaRequest, + IClusterClient, +} from '../../../../../src/core/server'; + +import { FeaturesService, LegacyAPI, SpacesService } from '../plugin'; +import { Actions } from './actions'; +import { CheckPrivilegesWithRequest, checkPrivilegesWithRequestFactory } from './check_privileges'; +import { + CheckPrivilegesDynamicallyWithRequest, + checkPrivilegesDynamicallyWithRequestFactory, +} from './check_privileges_dynamically'; +import { + CheckSavedObjectsPrivilegesWithRequest, + checkSavedObjectsPrivilegesWithRequestFactory, +} from './check_saved_objects_privileges'; +import { AuthorizationMode, authorizationModeFactory } from './mode'; +import { privilegesFactory, PrivilegesService } from './privileges'; +import { initAppAuthorization } from './app_authorization'; +import { initAPIAuthorization } from './api_authorization'; +import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; +import { validateFeaturePrivileges } from './validate_feature_privileges'; +import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; +import { APPLICATION_PREFIX } from '../../common/constants'; +import { SecurityLicense } from '../licensing'; + +export { Actions } from './actions'; +export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges'; + +interface SetupAuthorizationParams { + packageVersion: string; + http: CoreSetup['http']; + clusterClient: IClusterClient; + license: SecurityLicense; + loggers: LoggerFactory; + featuresService: FeaturesService; + getLegacyAPI(): Pick; + getSpacesService(): SpacesService | undefined; +} + +export interface Authorization { + actions: Actions; + checkPrivilegesWithRequest: CheckPrivilegesWithRequest; + checkPrivilegesDynamicallyWithRequest: CheckPrivilegesDynamicallyWithRequest; + checkSavedObjectsPrivilegesWithRequest: CheckSavedObjectsPrivilegesWithRequest; + getApplicationName: () => string; + mode: AuthorizationMode; + privileges: PrivilegesService; + disableUnauthorizedCapabilities: ( + request: KibanaRequest, + capabilities: UICapabilities + ) => Promise; + registerPrivilegesWithCluster: () => Promise; +} + +export function setupAuthorization({ + http, + packageVersion, + clusterClient, + license, + loggers, + featuresService, + getLegacyAPI, + getSpacesService, +}: SetupAuthorizationParams): Authorization { + const actions = new Actions(packageVersion); + const mode = authorizationModeFactory(license); + const getApplicationName = () => `${APPLICATION_PREFIX}${getLegacyAPI().kibanaIndexName}`; + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + actions, + clusterClient, + getApplicationName + ); + const privileges = privilegesFactory(actions, featuresService); + const logger = loggers.get('authorization'); + + const authz = { + actions, + getApplicationName, + checkPrivilegesWithRequest, + checkPrivilegesDynamicallyWithRequest: checkPrivilegesDynamicallyWithRequestFactory( + checkPrivilegesWithRequest, + getSpacesService + ), + checkSavedObjectsPrivilegesWithRequest: checkSavedObjectsPrivilegesWithRequestFactory( + checkPrivilegesWithRequest, + getSpacesService + ), + mode, + privileges, + + async disableUnauthorizedCapabilities(request: KibanaRequest, capabilities: UICapabilities) { + // If we have a license which doesn't enable security, or we're a legacy user we shouldn't + // disable any ui capabilities + if (!mode.useRbacForRequest(request)) { + return capabilities; + } + + const disableUICapabilities = disableUICapabilitiesFactory( + request, + featuresService.getFeatures(), + logger, + authz + ); + + // if we're an anonymous route, we disable all ui capabilities + if (request.route.options.authRequired === false) { + return disableUICapabilities.all(capabilities); + } + + return await disableUICapabilities.usingPrivileges(capabilities); + }, + + registerPrivilegesWithCluster: async () => { + validateFeaturePrivileges(actions, featuresService.getFeatures()); + + await registerPrivilegesWithCluster(logger, privileges, getApplicationName(), clusterClient); + }, + }; + + initAPIAuthorization(http, authz, loggers.get('api-authorization')); + initAppAuthorization(http, authz, loggers.get('app-authorization'), featuresService); + + return authz; +} diff --git a/x-pack/plugins/security/server/authorization/mode.test.ts b/x-pack/plugins/security/server/authorization/mode.test.ts new file mode 100644 index 0000000000000..3f6aa1f68ff0d --- /dev/null +++ b/x-pack/plugins/security/server/authorization/mode.test.ts @@ -0,0 +1,71 @@ +/* + * 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 { authorizationModeFactory } from './mode'; + +import { httpServerMock } from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../licensing/index.mock'; +import { SecurityLicenseFeatures } from '../licensing/license_features'; +import { SecurityLicense } from '../licensing'; + +describe(`#useRbacForRequest`, () => { + let mockLicense: jest.Mocked; + beforeEach(() => { + mockLicense = licenseMock.create(); + mockLicense.getFeatures.mockReturnValue({ allowRbac: false } as SecurityLicenseFeatures); + }); + + test(`throws an Error if request isn't specified`, async () => { + const mode = authorizationModeFactory(mockLicense); + expect(() => mode.useRbacForRequest(undefined as any)).toThrowErrorMatchingInlineSnapshot( + `"Invalid value used as weak map key"` + ); + }); + + test(`throws an Error if request is "null"`, async () => { + const mode = authorizationModeFactory(mockLicense); + + expect(() => mode.useRbacForRequest(null as any)).toThrowErrorMatchingInlineSnapshot( + `"Invalid value used as weak map key"` + ); + }); + + test(`returns false if "allowRbac" is false`, async () => { + const mode = authorizationModeFactory(mockLicense); + + const result = mode.useRbacForRequest(httpServerMock.createKibanaRequest()); + expect(result).toBe(false); + }); + + test(`returns false if "allowRbac" is initially false, and changes to true`, async () => { + const mode = authorizationModeFactory(mockLicense); + const request = httpServerMock.createKibanaRequest(); + + expect(mode.useRbacForRequest(request)).toBe(false); + + mockLicense.getFeatures.mockReturnValue({ allowRbac: true } as SecurityLicenseFeatures); + expect(mode.useRbacForRequest(request)).toBe(false); + }); + + test(`returns true if "allowRbac" is true`, async () => { + mockLicense.getFeatures.mockReturnValue({ allowRbac: true } as SecurityLicenseFeatures); + const mode = authorizationModeFactory(mockLicense); + + const result = mode.useRbacForRequest(httpServerMock.createKibanaRequest()); + expect(result).toBe(true); + }); + + test(`returns true if "allowRbac" is initially true, and changes to false`, async () => { + mockLicense.getFeatures.mockReturnValue({ allowRbac: true } as SecurityLicenseFeatures); + const mode = authorizationModeFactory(mockLicense); + const request = httpServerMock.createKibanaRequest(); + + expect(mode.useRbacForRequest(request)).toBe(true); + + mockLicense.getFeatures.mockReturnValue({ allowRbac: false } as SecurityLicenseFeatures); + expect(mode.useRbacForRequest(request)).toBe(true); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/mode.ts b/x-pack/plugins/security/server/authorization/mode.ts new file mode 100644 index 0000000000000..43ac8f43436fd --- /dev/null +++ b/x-pack/plugins/security/server/authorization/mode.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 { KibanaRequest } from '../../../../../src/core/server'; +import { SecurityLicense } from '../licensing'; + +export interface AuthorizationMode { + useRbacForRequest(request: KibanaRequest): boolean; +} + +export function authorizationModeFactory(license: SecurityLicense) { + const useRbacForRequestCache = new WeakMap(); + return { + useRbacForRequest(request: KibanaRequest) { + if (!useRbacForRequestCache.has(request)) { + useRbacForRequestCache.set(request, license.getFeatures().allowRbac); + } + + return useRbacForRequestCache.get(request)!; + }, + }; +} diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privilege_serializer.test.ts b/x-pack/plugins/security/server/authorization/privilege_serializer.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/privilege_serializer.test.ts rename to x-pack/plugins/security/server/authorization/privilege_serializer.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privilege_serializer.ts b/x-pack/plugins/security/server/authorization/privilege_serializer.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/privilege_serializer.ts rename to x-pack/plugins/security/server/authorization/privilege_serializer.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/api.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts similarity index 95% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/api.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts index 901c002bfde06..b13132f6efbe5 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/api.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeApiBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/app.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts similarity index 95% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/app.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts index 4362c79dc550e..c874886d908eb 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/app.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeAppBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/catalogue.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts similarity index 95% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/catalogue.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts index 5ed649b2726c2..3dbe71db93f4a 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/catalogue.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeCatalogueBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts similarity index 95% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts index 48078a26839bb..172ab24eb7e51 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { Actions } from '../../actions'; export interface FeaturePrivilegeBuilder { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/index.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts similarity index 97% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/index.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts index 78e1db7a980f3..c293319070419 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts @@ -5,7 +5,7 @@ */ import { flatten } from 'lodash'; -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { Actions } from '../../actions'; import { FeaturePrivilegeApiBuilder } from './api'; import { FeaturePrivilegeAppBuilder } from './app'; diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/management.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts similarity index 96% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/management.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts index 4a008fdb09619..99a4d11fb13b7 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/management.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeManagementBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/navlink.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts similarity index 94% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/navlink.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts index 3cd75233beffb..dd076477a9c11 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/navlink.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeNavlinkBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/saved_object.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts similarity index 97% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/saved_object.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts index 9bc67594b357a..9baa8dadc2923 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts @@ -5,7 +5,7 @@ */ import { flatten, uniq } from 'lodash'; -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; const readOperations: string[] = ['bulk_get', 'get', 'find']; diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/ui.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts similarity index 94% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/ui.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts index fd770b4c6263b..28a22285c2b8f 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/feature_privilege_builder/ui.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeUIBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/index.ts b/x-pack/plugins/security/server/authorization/privileges/index.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/index.ts rename to x-pack/plugins/security/server/authorization/privileges/index.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts similarity index 99% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/privileges.test.ts rename to x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 3d673cef40534..38d4d413c591e 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../../../../plugins/features/server'; +import { Feature } from '../../../../features/server'; import { Actions } from '../actions'; import { privilegesFactory } from './privileges'; @@ -42,11 +42,8 @@ describe('features', () => { }, ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), - }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockFeaturesService = { getFeatures: jest.fn().mockReturnValue(features) }; + const privileges = privilegesFactory(actions, mockFeaturesService); const actual = privileges.get(); expect(actual).toHaveProperty('features.foo-feature', { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts similarity index 90% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges/privileges.ts rename to x-pack/plugins/security/server/authorization/privileges/privileges.ts index aad48584a9fca..c73c4be8f36ac 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -5,22 +5,22 @@ */ import { flatten, mapValues, uniq } from 'lodash'; -import { Feature } from '../../../../../../../plugins/features/server'; -import { XPackMainPlugin } from '../../../../../xpack_main/xpack_main'; -import { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from '../../../../common/model'; +import { Feature } from '../../../../features/server'; +import { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from '../../../common/model'; import { Actions } from '../actions'; import { featurePrivilegeBuilderFactory } from './feature_privilege_builder'; +import { FeaturesService } from '../../plugin'; export interface PrivilegesService { get(): RawKibanaPrivileges; } -export function privilegesFactory(actions: Actions, xpackMainPlugin: XPackMainPlugin) { +export function privilegesFactory(actions: Actions, featuresService: FeaturesService) { const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); return { get() { - const features = xpackMainPlugin.getFeatures(); + const features = featuresService.getFeatures(); const basePrivilegeFeatures = features.filter(feature => !feature.excludeFromBasePrivileges); const allActions = uniq( diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges_serializer.test.ts b/x-pack/plugins/security/server/authorization/privileges_serializer.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges_serializer.test.ts rename to x-pack/plugins/security/server/authorization/privileges_serializer.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/privileges_serializer.ts b/x-pack/plugins/security/server/authorization/privileges_serializer.ts similarity index 97% rename from x-pack/legacy/plugins/security/server/lib/authorization/privileges_serializer.ts rename to x-pack/plugins/security/server/authorization/privileges_serializer.ts index ade90b5c52f90..3a101324ec196 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/privileges_serializer.ts +++ b/x-pack/plugins/security/server/authorization/privileges_serializer.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RawKibanaPrivileges } from '../../../common/model'; +import { RawKibanaPrivileges } from '../../common/model'; import { PrivilegeSerializer } from './privilege_serializer'; interface SerializedPrivilege { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts similarity index 74% rename from x-pack/legacy/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js rename to x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts index 23a7a0f0d01ab..888565cd7e0ff 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js +++ b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts @@ -4,215 +4,165 @@ * you may not use this file except in compliance with the Elastic License. */ +import { IClusterClient, Logger } from '../../../../../target/types/core/server'; +import { RawKibanaPrivileges } from '../../common/model'; import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { buildRawKibanaPrivileges } from './privileges'; -jest.mock('../../../../../server/lib/get_client_shield', () => ({ - getClient: jest.fn(), -})); -jest.mock('./privileges', () => ({ - buildRawKibanaPrivileges: jest.fn(), -})); -const application = 'default-application'; - -const registerPrivilegesWithClusterTest = (description, { - settings = {}, - savedObjectTypes, - privilegeMap, - existingPrivileges, - throwErrorWhenDeletingPrivileges, - errorDeletingPrivilegeName, - throwErrorWhenGettingPrivileges, - throwErrorWhenPuttingPrivileges, - assert -}) => { - const registerMockCallWithInternalUser = () => { - const callWithInternalUser = jest.fn(); - getClient.mockReturnValue({ - callWithInternalUser, - }); - return callWithInternalUser; - }; - - const defaultVersion = 'default-version'; - - const createMockServer = ({ privilegeMap }) => { - const mockServer = { - config: jest.fn().mockReturnValue({ - get: jest.fn(), - }), - log: jest.fn(), - plugins: { - security: { - authorization: { - actions: Symbol(), - application, - privileges: { - get: () => privilegeMap - } - } - } - } - }; - - const defaultSettings = { - 'pkg.version': defaultVersion, - }; - - mockServer.config().get.mockImplementation(key => { - return key in settings ? settings[key] : defaultSettings[key]; - }); - - mockServer.savedObjects = { - types: savedObjectTypes - }; +import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; - return mockServer; - }; - - const createExpectUpdatedPrivileges = (mockServer, mockCallWithInternalUser, error) => { - return (postPrivilegesBody, deletedPrivileges = []) => { +const application = 'default-application'; +const registerPrivilegesWithClusterTest = ( + description: string, + { + privilegeMap, + existingPrivileges, + throwErrorWhenGettingPrivileges, + throwErrorWhenPuttingPrivileges, + assert, + }: { + privilegeMap: RawKibanaPrivileges; + existingPrivileges?: Record> | null; + throwErrorWhenGettingPrivileges?: Error; + throwErrorWhenPuttingPrivileges?: Error; + assert: (arg: { + expectUpdatedPrivileges: (postPrivilegesBody: any, deletedPrivileges?: string[]) => void; + expectDidntUpdatePrivileges: () => void; + expectErrorThrown: (expectedErrorMessage: string) => void; + }) => void; + } +) => { + const createExpectUpdatedPrivileges = ( + mockClusterClient: jest.Mocked, + mockLogger: jest.Mocked, + error: Error + ) => { + return (postPrivilegesBody: any, deletedPrivileges: string[] = []) => { expect(error).toBeUndefined(); - expect(mockCallWithInternalUser).toHaveBeenCalledTimes(2 + deletedPrivileges.length); - expect(mockCallWithInternalUser).toHaveBeenCalledWith('shield.getPrivilege', { + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes( + 2 + deletedPrivileges.length + ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getPrivilege', { privilege: application, }); - expect(mockCallWithInternalUser).toHaveBeenCalledWith( - 'shield.postPrivileges', - { - body: postPrivilegesBody, - } - ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.postPrivileges', { + body: postPrivilegesBody, + }); for (const deletedPrivilege of deletedPrivileges) { - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'debug'], + expect(mockLogger.debug).toHaveBeenCalledWith( `Deleting Kibana Privilege ${deletedPrivilege} from Elasticearch for ${application}` ); - expect(mockCallWithInternalUser).toHaveBeenCalledWith( + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deletePrivilege', - { - application, - privilege: deletedPrivilege - } + { application, privilege: deletedPrivilege } ); } - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'debug'], + + expect(mockLogger.debug).toHaveBeenCalledWith( `Registering Kibana Privileges with Elasticsearch for ${application}` ); - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'debug'], - `Updated Kibana Privileges with Elasticearch for ${application}` + expect(mockLogger.debug).toHaveBeenCalledWith( + `Updated Kibana Privileges with Elasticsearch for ${application}` ); }; }; - const createExpectDidntUpdatePrivileges = (mockServer, mockCallWithInternalUser, error) => { + const createExpectDidntUpdatePrivileges = ( + mockClusterClient: jest.Mocked, + mockLogger: Logger, + error: Error + ) => { return () => { expect(error).toBeUndefined(); - expect(mockCallWithInternalUser).toHaveBeenCalledTimes(1); - expect(mockCallWithInternalUser).toHaveBeenLastCalledWith('shield.getPrivilege', { - privilege: application + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenLastCalledWith('shield.getPrivilege', { + privilege: application, }); - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'debug'], + expect(mockLogger.debug).toHaveBeenCalledWith( `Registering Kibana Privileges with Elasticsearch for ${application}` ); - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'debug'], + expect(mockLogger.debug).toHaveBeenCalledWith( `Kibana Privileges already registered with Elasticearch for ${application}` ); }; }; - const createExpectErrorThrown = (mockServer, actualError) => { - return (expectedErrorMessage) => { + const createExpectErrorThrown = (mockLogger: Logger, actualError: Error) => { + return (expectedErrorMessage: string) => { expect(actualError).toBeDefined(); expect(actualError).toBeInstanceOf(Error); expect(actualError.message).toEqual(expectedErrorMessage); - if (throwErrorWhenDeletingPrivileges) { - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'error'], - `Error deleting Kibana Privilege ${errorDeletingPrivilegeName}` - ); - } - - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'error'], + expect(mockLogger.error).toHaveBeenCalledWith( `Error registering Kibana Privileges with Elasticsearch for ${application}: ${expectedErrorMessage}` ); }; }; test(description, async () => { - const mockServer = createMockServer({ - privilegeMap - }); - const mockCallWithInternalUser = registerMockCallWithInternalUser() - .mockImplementation((api) => { - switch(api) { - case 'shield.getPrivilege': { - if (throwErrorWhenGettingPrivileges) { - throw throwErrorWhenGettingPrivileges; - } - - // ES returns an empty object if we don't have any privileges - if (!existingPrivileges) { - return {}; - } - - return existingPrivileges; + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient.callAsInternalUser.mockImplementation(async api => { + switch (api) { + case 'shield.getPrivilege': { + if (throwErrorWhenGettingPrivileges) { + throw throwErrorWhenGettingPrivileges; } - case 'shield.deletePrivilege': { - if (throwErrorWhenDeletingPrivileges) { - throw throwErrorWhenDeletingPrivileges; - } - break; + // ES returns an empty object if we don't have any privileges + if (!existingPrivileges) { + return {}; } - case 'shield.postPrivileges': { - if (throwErrorWhenPuttingPrivileges) { - throw throwErrorWhenPuttingPrivileges; - } - return; - } - default: { - expect(true).toBe(false); + return existingPrivileges; + } + case 'shield.deletePrivilege': { + break; + } + case 'shield.postPrivileges': { + if (throwErrorWhenPuttingPrivileges) { + throw throwErrorWhenPuttingPrivileges; } + + return; } - }); + default: { + expect(true).toBe(false); + } + } + }); + const mockLogger = loggingServiceMock.create().get() as jest.Mocked; let error; try { - await registerPrivilegesWithCluster(mockServer); + await registerPrivilegesWithCluster( + mockLogger, + { get: jest.fn().mockReturnValue(privilegeMap) }, + application, + mockClusterClient + ); } catch (err) { error = err; } assert({ - expectUpdatedPrivileges: createExpectUpdatedPrivileges(mockServer, mockCallWithInternalUser, error), - expectDidntUpdatePrivileges: createExpectDidntUpdatePrivileges(mockServer, mockCallWithInternalUser, error), - expectErrorThrown: createExpectErrorThrown(mockServer, error), - mocks: { - buildRawKibanaPrivileges, - server: mockServer, - } + expectUpdatedPrivileges: createExpectUpdatedPrivileges(mockClusterClient, mockLogger, error), + expectDidntUpdatePrivileges: createExpectDidntUpdatePrivileges( + mockClusterClient, + mockLogger, + error + ), + expectErrorThrown: createExpectErrorThrown(mockLogger, error), }); }); }; registerPrivilegesWithClusterTest(`inserts privileges when we don't have any existing privileges`, { privilegeMap: { - features: {}, global: { - all: ['action:all'] + all: ['action:all'], }, space: { - read: ['action:read'] + read: ['action:read'], }, features: { foo: { @@ -220,11 +170,11 @@ registerPrivilegesWithClusterTest(`inserts privileges when we don't have any exi }, bar: { read: ['action:bar_read'], - } + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: null, assert: ({ expectUpdatedPrivileges }) => { @@ -259,10 +209,10 @@ registerPrivilegesWithClusterTest(`inserts privileges when we don't have any exi name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`deletes no-longer specified privileges`, { @@ -272,7 +222,7 @@ registerPrivilegesWithClusterTest(`deletes no-longer specified privileges`, { all: ['action:foo'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, reserved: {}, }, @@ -307,49 +257,51 @@ registerPrivilegesWithClusterTest(`deletes no-longer specified privileges`, { name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { - expectUpdatedPrivileges({ - [application]: { - all: { - application, - name: 'all', - actions: ['action:foo'], - metadata: {}, + expectUpdatedPrivileges( + { + [application]: { + all: { + application, + name: 'all', + actions: ['action:foo'], + metadata: {}, + }, + space_read: { + application, + name: 'space_read', + actions: ['action:bar'], + metadata: {}, + }, }, - space_read: { - application, - name: 'space_read', - actions: ['action:bar'], - metadata: {}, - } - } - }, ['read', 'space_baz', 'reserved_customApplication']); - } + }, + ['read', 'space_baz', 'reserved_customApplication'] + ); + }, }); registerPrivilegesWithClusterTest(`updates privileges when global actions don't match`, { privilegeMap: { - features: {}, global: { - all: ['action:foo'] + all: ['action:foo'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, features: { foo: { - all: ['action:baz'] + all: ['action:baz'], }, bar: { - read: ['action:quz'] - } + read: ['action:quz'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -380,8 +332,8 @@ registerPrivilegesWithClusterTest(`updates privileges when global actions don't name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -415,32 +367,31 @@ registerPrivilegesWithClusterTest(`updates privileges when global actions don't name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when space actions don't match`, { privilegeMap: { - features: {}, global: { - all: ['action:foo'] + all: ['action:foo'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, features: { foo: { - all: ['action:baz'] + all: ['action:baz'], }, bar: { - read: ['action:quz'] - } + read: ['action:quz'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -471,8 +422,8 @@ registerPrivilegesWithClusterTest(`updates privileges when space actions don't m name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -506,32 +457,31 @@ registerPrivilegesWithClusterTest(`updates privileges when space actions don't m name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when feature actions don't match`, { privilegeMap: { - features: {}, global: { - all: ['action:foo'] + all: ['action:foo'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, features: { foo: { - all: ['action:baz'] + all: ['action:baz'], }, bar: { - read: ['action:quz'] - } + read: ['action:quz'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -562,8 +512,8 @@ registerPrivilegesWithClusterTest(`updates privileges when feature actions don't name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -597,29 +547,28 @@ registerPrivilegesWithClusterTest(`updates privileges when feature actions don't name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when reserved actions don't match`, { privilegeMap: { - features: {}, global: { - all: ['action:foo'] + all: ['action:foo'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, features: { foo: { - all: ['action:baz'] - } + all: ['action:baz'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -645,8 +594,8 @@ registerPrivilegesWithClusterTest(`updates privileges when reserved actions don' name: 'reserved_customApplication', actions: ['action:not-customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -674,29 +623,29 @@ registerPrivilegesWithClusterTest(`updates privileges when reserved actions don' name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when global privilege added`, { privilegeMap: { global: { all: ['action:foo'], - read: ['action:quz'] + read: ['action:quz'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, features: { foo: { - all: ['action:foo-all'] - } + all: ['action:foo-all'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -723,8 +672,8 @@ registerPrivilegesWithClusterTest(`updates privileges when global privilege adde name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -758,10 +707,10 @@ registerPrivilegesWithClusterTest(`updates privileges when global privilege adde name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when space privilege added`, { @@ -771,16 +720,16 @@ registerPrivilegesWithClusterTest(`updates privileges when space privilege added }, space: { all: ['action:bar'], - read: ['action:quz'] + read: ['action:quz'], }, features: { foo: { - all: ['action:foo-all'] - } + all: ['action:foo-all'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -807,8 +756,8 @@ registerPrivilegesWithClusterTest(`updates privileges when space privilege added name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -842,15 +791,14 @@ registerPrivilegesWithClusterTest(`updates privileges when space privilege added name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when feature privilege added`, { privilegeMap: { - features: {}, global: { all: ['action:foo'], }, @@ -860,12 +808,12 @@ registerPrivilegesWithClusterTest(`updates privileges when feature privilege add features: { foo: { all: ['action:foo-all'], - read: ['action:foo-read'] - } + read: ['action:foo-read'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -892,8 +840,8 @@ registerPrivilegesWithClusterTest(`updates privileges when feature privilege add name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -927,15 +875,14 @@ registerPrivilegesWithClusterTest(`updates privileges when feature privilege add name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when reserved privilege added`, { privilegeMap: { - features: {}, global: { all: ['action:foo'], }, @@ -945,12 +892,12 @@ registerPrivilegesWithClusterTest(`updates privileges when reserved privilege ad features: { foo: { all: ['action:foo-all'], - } + }, }, reserved: { customApplication1: ['action:customApplication1'], - customApplication2: ['action:customApplication2'] - } + customApplication2: ['action:customApplication2'], + }, }, existingPrivileges: { [application]: { @@ -977,8 +924,8 @@ registerPrivilegesWithClusterTest(`updates privileges when reserved privilege ad name: 'reserved_customApplication1', actions: ['action:customApplication1'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -1012,28 +959,28 @@ registerPrivilegesWithClusterTest(`updates privileges when reserved privilege ad name: 'reserved_customApplication2', actions: ['action:customApplication2'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`doesn't update privileges when order of actions differ`, { privilegeMap: { global: { - all: ['action:foo', 'action:quz'] + all: ['action:foo', 'action:quz'], }, space: { - read: ['action:bar', 'action:quz'] + read: ['action:bar', 'action:quz'], }, features: { foo: { - all: ['action:foo-all', 'action:bar-all'] - } + all: ['action:foo-all', 'action:bar-all'], + }, }, reserved: { - customApplication: ['action:customApplication1', 'action:customApplication2'] - } + customApplication: ['action:customApplication1', 'action:customApplication2'], + }, }, existingPrivileges: { [application]: { @@ -1060,12 +1007,12 @@ registerPrivilegesWithClusterTest(`doesn't update privileges when order of actio name: 'reserved_customApplication', actions: ['action:customApplication2', 'action:customApplication1'], metadata: {}, - } - } + }, + }, }, assert: ({ expectDidntUpdatePrivileges }) => { expectDidntUpdatePrivileges(); - } + }, }); registerPrivilegesWithClusterTest(`throws and logs error when errors getting privileges`, { @@ -1078,17 +1025,17 @@ registerPrivilegesWithClusterTest(`throws and logs error when errors getting pri throwErrorWhenGettingPrivileges: new Error('Error getting privileges'), assert: ({ expectErrorThrown }) => { expectErrorThrown('Error getting privileges'); - } + }, }); registerPrivilegesWithClusterTest(`throws and logs error when errors putting privileges`, { privilegeMap: { features: {}, global: { - all: [] + all: [], }, space: { - read: [] + read: [], }, reserved: {}, }, @@ -1096,5 +1043,5 @@ registerPrivilegesWithClusterTest(`throws and logs error when errors putting pri throwErrorWhenPuttingPrivileges: new Error('Error putting privileges'), assert: ({ expectErrorThrown }) => { expectErrorThrown('Error putting privileges'); - } + }, }); diff --git a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts new file mode 100644 index 0000000000000..22e7830d20e28 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts @@ -0,0 +1,92 @@ +/* + * 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 { isEqual, difference } from 'lodash'; +import { IClusterClient, Logger } from '../../../../../src/core/server'; + +import { serializePrivileges } from './privileges_serializer'; +import { PrivilegesService } from './privileges'; + +export async function registerPrivilegesWithCluster( + logger: Logger, + privileges: PrivilegesService, + application: string, + clusterClient: IClusterClient +) { + const arePrivilegesEqual = ( + existingPrivileges: Record, + expectedPrivileges: Record + ) => { + // when comparing privileges, the order of the actions doesn't matter, lodash's isEqual + // doesn't know how to compare Sets + return isEqual(existingPrivileges, expectedPrivileges, (value, other, key) => { + if (key === 'actions' && Array.isArray(value) && Array.isArray(other)) { + // Array.sort() is in-place, and we don't want to be modifying the actual order + // of the arrays permanently, and there's potential they're frozen, so we're copying + // before comparing. + 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; + }); + }; + + const getPrivilegesToDelete = ( + existingPrivileges: Record, + expectedPrivileges: Record + ) => { + if (Object.keys(existingPrivileges).length === 0) { + return []; + } + + return difference( + Object.keys(existingPrivileges[application]), + Object.keys(expectedPrivileges[application]) + ); + }; + + const expectedPrivileges = serializePrivileges(application, privileges.get()); + + logger.debug(`Registering Kibana Privileges with Elasticsearch for ${application}`); + + try { + // we only want to post the privileges when they're going to change as Elasticsearch has + // to clear the role cache to get these changes reflected in the _has_privileges API + const existingPrivileges = await clusterClient.callAsInternalUser('shield.getPrivilege', { + privilege: application, + }); + if (arePrivilegesEqual(existingPrivileges, expectedPrivileges)) { + logger.debug(`Kibana Privileges already registered with Elasticearch for ${application}`); + return; + } + + const privilegesToDelete = getPrivilegesToDelete(existingPrivileges, expectedPrivileges); + for (const privilegeToDelete of privilegesToDelete) { + logger.debug( + `Deleting Kibana Privilege ${privilegeToDelete} from Elasticearch for ${application}` + ); + try { + await clusterClient.callAsInternalUser('shield.deletePrivilege', { + application, + privilege: privilegeToDelete, + }); + } catch (err) { + logger.error(`Error deleting Kibana Privilege ${privilegeToDelete}`); + throw err; + } + } + + await clusterClient.callAsInternalUser('shield.postPrivileges', { body: expectedPrivileges }); + logger.debug(`Updated Kibana Privileges with Elasticsearch for ${application}`); + } catch (err) { + logger.error( + `Error registering Kibana Privileges with Elasticsearch for ${application}: ${err.message}` + ); + throw err; + } +} diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/resource_serializer.test.ts b/x-pack/plugins/security/server/authorization/resource_serializer.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/resource_serializer.test.ts rename to x-pack/plugins/security/server/authorization/resource_serializer.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/resource_serializer.ts b/x-pack/plugins/security/server/authorization/resource_serializer.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/resource_serializer.ts rename to x-pack/plugins/security/server/authorization/resource_serializer.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/service.test.mocks.ts b/x-pack/plugins/security/server/authorization/service.test.mocks.ts similarity index 81% rename from x-pack/legacy/plugins/security/server/lib/authorization/service.test.mocks.ts rename to x-pack/plugins/security/server/authorization/service.test.mocks.ts index a766b60894d99..5cd2eac20094d 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/service.test.mocks.ts +++ b/x-pack/plugins/security/server/authorization/service.test.mocks.ts @@ -19,16 +19,6 @@ jest.mock('./check_saved_objects_privileges', () => ({ checkSavedObjectsPrivilegesWithRequestFactory: mockCheckSavedObjectsPrivilegesWithRequestFactory, })); -export const mockGetClient = jest.fn(); -jest.mock('../../../../../server/lib/get_client_shield', () => ({ - getClient: mockGetClient, -})); - -export const mockActionsFactory = jest.fn(); -jest.mock('./actions', () => ({ - actionsFactory: mockActionsFactory, -})); - export const mockPrivilegesFactory = jest.fn(); jest.mock('./privileges', () => ({ privilegesFactory: mockPrivilegesFactory, diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/types.ts b/x-pack/plugins/security/server/authorization/types.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/types.ts rename to x-pack/plugins/security/server/authorization/types.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/validate_es_response.test.ts b/x-pack/plugins/security/server/authorization/validate_es_response.test.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/validate_es_response.test.ts rename to x-pack/plugins/security/server/authorization/validate_es_response.test.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/validate_es_response.ts b/x-pack/plugins/security/server/authorization/validate_es_response.ts similarity index 100% rename from x-pack/legacy/plugins/security/server/lib/authorization/validate_es_response.ts rename to x-pack/plugins/security/server/authorization/validate_es_response.ts diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/validate_feature_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts similarity index 84% rename from x-pack/legacy/plugins/security/server/lib/authorization/validate_feature_privileges.test.ts rename to x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts index 6745a00091cee..3dc3ae03b18cb 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/validate_feature_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts @@ -4,20 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../../../plugins/features/server'; -import { actionsFactory } from './actions'; +import { Feature } from '../../../features/server'; +import { Actions } from './actions'; import { validateFeaturePrivileges } from './validate_feature_privileges'; -const mockConfig = { - get: (key: string) => { - if (key === 'pkg.version') { - return `1.0.0-zeta1`; - } - - throw new Error(`Mock config doesn't know about key ${key}`); - }, -}; -const actions = actionsFactory(mockConfig); +const actions = new Actions('1.0.0-zeta1'); it(`doesn't allow read to grant privileges which aren't also included in all`, () => { const feature: Feature = { diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/validate_feature_privileges.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts similarity index 87% rename from x-pack/legacy/plugins/security/server/lib/authorization/validate_feature_privileges.ts rename to x-pack/plugins/security/server/authorization/validate_feature_privileges.ts index 0e40ae36c4f72..7998c816ae1c7 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/validate_feature_privileges.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../../../plugins/features/server'; -import { areActionsFullyCovered } from '../../../common/privilege_calculator_utils'; +import { Feature } from '../../../features/server'; +import { areActionsFullyCovered } from '../../common/privilege_calculator_utils'; import { Actions } from './actions'; import { featurePrivilegeBuilderFactory } from './privileges/feature_privilege_builder'; diff --git a/x-pack/plugins/security/server/licensing/index.mock.ts b/x-pack/plugins/security/server/licensing/index.mock.ts new file mode 100644 index 0000000000000..b38f031c4ee7d --- /dev/null +++ b/x-pack/plugins/security/server/licensing/index.mock.ts @@ -0,0 +1,14 @@ +/* + * 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 { SecurityLicense } from '.'; + +export const licenseMock = { + create: (): jest.Mocked => ({ + isEnabled: jest.fn().mockReturnValue(true), + getFeatures: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/server/licensing/index.ts b/x-pack/plugins/security/server/licensing/index.ts new file mode 100644 index 0000000000000..9ddbe86167367 --- /dev/null +++ b/x-pack/plugins/security/server/licensing/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SecurityLicenseService, SecurityLicense } from './license_service'; diff --git a/x-pack/plugins/security/server/licensing/license_features.ts b/x-pack/plugins/security/server/licensing/license_features.ts new file mode 100644 index 0000000000000..6b6c86d48c21e --- /dev/null +++ b/x-pack/plugins/security/server/licensing/license_features.ts @@ -0,0 +1,50 @@ +/* + * 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 Security plugin features that depend on license. + */ +export interface SecurityLicenseFeatures { + /** + * Indicates whether we show login page or skip it. + */ + readonly showLogin: boolean; + + /** + * Indicates whether we allow login or disable it on the login page. + */ + readonly allowLogin: boolean; + + /** + * Indicates whether we show security links throughout the kibana app. + */ + readonly showLinks: boolean; + + /** + * Indicates whether we allow users to define document level security in roles. + */ + readonly allowRoleDocumentLevelSecurity: boolean; + + /** + * Indicates whether we allow users to define field level security in roles. + */ + readonly allowRoleFieldLevelSecurity: boolean; + + /** + * Indicates whether we allow Role-based access control (RBAC). + */ + readonly allowRbac: boolean; + + /** + * Describes the layout of the login form if it's displayed. + */ + readonly layout?: string; + + /** + * Message to show when security links are clicked throughout the kibana app. + */ + readonly linksMessage?: string; +} diff --git a/x-pack/plugins/security/server/licensing/license_service.test.ts b/x-pack/plugins/security/server/licensing/license_service.test.ts new file mode 100644 index 0000000000000..16d7599ca4b1a --- /dev/null +++ b/x-pack/plugins/security/server/licensing/license_service.test.ts @@ -0,0 +1,104 @@ +/* + * 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 { ILicense } from '../../../licensing/server'; +import { SecurityLicenseService } from './license_service'; + +function getMockRawLicense({ isAvailable = false } = {}) { + return ({ + isAvailable, + isOneOf: jest.fn(), + getFeature: jest.fn(), + } as unknown) as jest.Mocked; +} + +describe('license features', function() { + it('should display error when ES is unavailable', () => { + const serviceSetup = new SecurityLicenseService().setup(); + expect(serviceSetup.license.getFeatures()).toEqual({ + showLogin: true, + allowLogin: false, + showLinks: false, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + layout: 'error-es-unavailable', + allowRbac: false, + }); + }); + + it('should display error when X-Pack is unavailable', () => { + const serviceSetup = new SecurityLicenseService().setup(); + serviceSetup.update(getMockRawLicense({ isAvailable: false })); + expect(serviceSetup.license.getFeatures()).toEqual({ + showLogin: true, + allowLogin: false, + showLinks: false, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + layout: 'error-xpack-unavailable', + allowRbac: false, + }); + }); + + it('should show login page and other security elements, allow RBAC but forbid document level security if license is not platinum or trial.', () => { + const mockRawLicense = getMockRawLicense({ isAvailable: true }); + mockRawLicense.isOneOf.mockImplementation(licenses => + Array.isArray(licenses) ? licenses.includes('basic') : licenses === 'basic' + ); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true } as any); + + const serviceSetup = new SecurityLicenseService().setup(); + serviceSetup.update(mockRawLicense); + expect(serviceSetup.license.getFeatures()).toEqual({ + showLogin: true, + allowLogin: true, + showLinks: true, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + allowRbac: true, + }); + expect(mockRawLicense.getFeature).toHaveBeenCalledTimes(1); + expect(mockRawLicense.getFeature).toHaveBeenCalledWith('security'); + }); + + it('should not show login page or other security elements if security is disabled in Elasticsearch.', () => { + const mockRawLicense = getMockRawLicense({ isAvailable: true }); + mockRawLicense.isOneOf.mockReturnValue(false); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: false, isAvailable: true } as any); + + const serviceSetup = new SecurityLicenseService().setup(); + serviceSetup.update(mockRawLicense); + expect(serviceSetup.license.getFeatures()).toEqual({ + showLogin: false, + allowLogin: false, + showLinks: false, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + allowRbac: false, + linksMessage: 'Access is denied because Security is disabled in Elasticsearch.', + }); + }); + + it('should allow to login, allow RBAC and document level security if license is platinum or trial.', () => { + const mockRawLicense = getMockRawLicense({ isAvailable: true }); + mockRawLicense.isOneOf.mockImplementation(licenses => { + const licenseArray = [licenses].flat(); + return licenseArray.includes('trial') || licenseArray.includes('platinum'); + }); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true } as any); + + const serviceSetup = new SecurityLicenseService().setup(); + serviceSetup.update(mockRawLicense); + expect(serviceSetup.license.getFeatures()).toEqual({ + showLogin: true, + allowLogin: true, + showLinks: true, + allowRoleDocumentLevelSecurity: true, + allowRoleFieldLevelSecurity: true, + allowRbac: true, + }); + }); +}); diff --git a/x-pack/plugins/security/server/licensing/license_service.ts b/x-pack/plugins/security/server/licensing/license_service.ts new file mode 100644 index 0000000000000..58c445de9319d --- /dev/null +++ b/x-pack/plugins/security/server/licensing/license_service.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 { deepFreeze } from '../../../../../src/core/utils'; +import { ILicense } from '../../../licensing/server'; +import { SecurityLicenseFeatures } from './license_features'; + +export interface SecurityLicense { + isEnabled(): boolean; + getFeatures(): SecurityLicenseFeatures; +} + +export class SecurityLicenseService { + public setup() { + let rawLicense: Readonly | undefined; + + return { + update(newRawLicense: Readonly) { + rawLicense = newRawLicense; + }, + + license: deepFreeze({ + isEnabled() { + if (!rawLicense) { + return false; + } + + const securityFeature = rawLicense.getFeature('security'); + return ( + securityFeature !== undefined && + securityFeature.isAvailable && + securityFeature.isEnabled + ); + }, + + /** + * Returns up-do-date Security related features based on the last known license. + */ + getFeatures(): SecurityLicenseFeatures { + // If, for some reason, we cannot get license information from Elasticsearch, + // assume worst-case and lock user at login screen. + if (rawLicense === undefined || !rawLicense.isAvailable) { + return { + showLogin: true, + allowLogin: false, + showLinks: false, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + allowRbac: false, + layout: + rawLicense !== undefined && !rawLicense.isAvailable + ? 'error-xpack-unavailable' + : 'error-es-unavailable', + }; + } + + if (!this.isEnabled()) { + return { + showLogin: false, + allowLogin: false, + showLinks: false, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + allowRbac: false, + linksMessage: 'Access is denied because Security is disabled in Elasticsearch.', + }; + } + + const isLicensePlatinumOrTrial = rawLicense.isOneOf(['platinum', 'trial']); + return { + showLogin: true, + allowLogin: true, + showLinks: true, + // Only platinum and trial licenses are compliant with field- and document-level security. + allowRoleDocumentLevelSecurity: isLicensePlatinumOrTrial, + allowRoleFieldLevelSecurity: isLicensePlatinumOrTrial, + allowRbac: true, + }; + }, + }), + }; + } +} diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts new file mode 100644 index 0000000000000..d5c08d5ab1ab9 --- /dev/null +++ b/x-pack/plugins/security/server/mocks.ts @@ -0,0 +1,28 @@ +/* + * 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 { PluginSetupContract } from './plugin'; + +import { authenticationMock } from './authentication/index.mock'; +import { authorizationMock } from './authorization/index.mock'; + +function createSetupMock() { + const mockAuthz = authorizationMock.create(); + return { + authc: authenticationMock.create(), + authz: { + actions: mockAuthz.actions, + checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest, + mode: mockAuthz.mode, + }, + registerSpacesService: jest.fn(), + __legacyCompat: {} as PluginSetupContract['__legacyCompat'], + }; +} + +export const securityMock = { + createSetup: createSetupMock, +}; diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 7fa8f20476f90..b0e2ae7176834 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -4,16 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; - +import { of } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; -import { Plugin } from './plugin'; import { IClusterClient, CoreSetup } from '../../../../src/core/server'; +import { Plugin, PluginSetupDependencies } from './plugin'; + +import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; describe('Security Plugin', () => { let plugin: Plugin; let mockCoreSetup: MockedKeys; let mockClusterClient: jest.Mocked; + let mockDependencies: PluginSetupDependencies; beforeEach(() => { plugin = new Plugin( coreMock.createPluginInitializerContext({ @@ -33,12 +35,33 @@ describe('Security Plugin', () => { mockCoreSetup.elasticsearch.createClient.mockReturnValue( (mockClusterClient as unknown) as jest.Mocked ); + + mockDependencies = { licensing: { license$: of({}) } } as PluginSetupDependencies; }); describe('setup()', () => { it('exposes proper contract', async () => { - await expect(plugin.setup(mockCoreSetup)).resolves.toMatchInlineSnapshot(` + await expect(plugin.setup(mockCoreSetup, mockDependencies)).resolves.toMatchInlineSnapshot(` Object { + "__legacyCompat": Object { + "config": Object { + "authc": Object { + "providers": Array [ + "saml", + "token", + ], + }, + "cookieName": "sid", + "secureCookies": true, + "sessionTimeout": 1500, + }, + "license": Object { + "getFeatures": [Function], + "isEnabled": [Function], + }, + "registerLegacyAPI": [Function], + "registerPrivilegesWithCluster": [Function], + }, "authc": Object { "createAPIKey": [Function], "getCurrentUser": [Function], @@ -47,24 +70,40 @@ describe('Security Plugin', () => { "login": [Function], "logout": [Function], }, - "config": Object { - "authc": Object { - "providers": Array [ - "saml", - "token", - ], + "authz": Object { + "actions": Actions { + "allHack": "allHack:", + "api": ApiActions { + "prefix": "api:version:", + }, + "app": AppActions { + "prefix": "app:version:", + }, + "login": "login:", + "savedObject": SavedObjectActions { + "prefix": "saved_object:version:", + }, + "space": SpaceActions { + "prefix": "space:version:", + }, + "ui": UIActions { + "prefix": "ui:version:", + }, + "version": "version:version", + "versionNumber": "version", + }, + "checkPrivilegesWithRequest": [Function], + "mode": Object { + "useRbacForRequest": [Function], }, - "cookieName": "sid", - "secureCookies": true, - "sessionTimeout": 1500, }, - "registerLegacyAPI": [Function], + "registerSpacesService": [Function], } `); }); it('properly creates cluster client instance', async () => { - await plugin.setup(mockCoreSetup); + await plugin.setup(mockCoreSetup, mockDependencies); expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledTimes(1); expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledWith('security', { @@ -74,7 +113,7 @@ describe('Security Plugin', () => { }); describe('stop()', () => { - beforeEach(async () => await plugin.setup(mockCoreSetup)); + beforeEach(async () => await plugin.setup(mockCoreSetup, mockDependencies)); it('properly closes cluster client instance', async () => { expect(mockClusterClient.close).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 18717f3e132b9..4b3997fe74f1b 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; import { IClusterClient, @@ -12,21 +13,43 @@ import { Logger, PluginInitializerContext, RecursiveReadonly, + SavedObjectsLegacyService, + LegacyRequest, } from '../../../../src/core/server'; import { deepFreeze } from '../../../../src/core/utils'; -import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info'; -import { setupAuthentication, Authentication } from './authentication'; +import { SpacesPluginSetup } from '../../spaces/server'; +import { PluginSetupContract as FeaturesSetupContract } from '../../features/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { CapabilitiesModifier } from '../../../../src/legacy/server/capabilities'; + +import { Authentication, setupAuthentication } from './authentication'; +import { Authorization, setupAuthorization } from './authorization'; import { createConfig$ } from './config'; import { defineRoutes } from './routes'; +import { SecurityLicenseService, SecurityLicense } from './licensing'; +import { setupSavedObjects } from './saved_objects'; +import { SecurityAuditLogger } from './audit'; + +export type SpacesService = Pick< + SpacesPluginSetup['spacesService'], + 'getSpaceId' | 'namespaceToSpaceId' +>; + +export type FeaturesService = Pick; /** * Describes a set of APIs that is available in the legacy platform only and required by this plugin * to function properly. */ export interface LegacyAPI { - xpackInfo: Pick; isSystemAPIRequest: (request: KibanaRequest) => boolean; + capabilities: { registerCapabilitiesModifier: (provider: CapabilitiesModifier) => void }; + kibanaIndexName: string; cspRules: string; + savedObjects: SavedObjectsLegacyService; + auditLogger: { + log: (eventType: string, message: string, data?: Record) => void; + }; } /** @@ -34,14 +57,33 @@ export interface LegacyAPI { */ export interface PluginSetupContract { authc: Authentication; + authz: Pick; + + /** + * If Spaces plugin is available it's supposed to register its SpacesService with Security plugin + * so that Security can get space ID from the URL or namespace. We can't declare optional dependency + * to Spaces since it'd result into circular dependency between these two plugins and circular + * dependencies aren't supported by the Core. In the future we have to get rid of this implicit + * dependency. + * @param service Spaces service exposed by the Spaces plugin. + */ + registerSpacesService: (service: SpacesService) => void; + + __legacyCompat: { + registerLegacyAPI: (legacyAPI: LegacyAPI) => void; + registerPrivilegesWithCluster: () => void; + license: SecurityLicense; + config: RecursiveReadonly<{ + sessionTimeout: number | null; + secureCookies: boolean; + authc: { providers: string[] }; + }>; + }; +} - config: RecursiveReadonly<{ - sessionTimeout: number | null; - secureCookies: boolean; - authc: { providers: string[] }; - }>; - - registerLegacyAPI: (legacyAPI: LegacyAPI) => void; +export interface PluginSetupDependencies { + features: FeaturesService; + licensing: LicensingPluginSetup; } /** @@ -50,6 +92,8 @@ export interface PluginSetupContract { export class Plugin { private readonly logger: Logger; private clusterClient?: IClusterClient; + private spacesService?: SpacesService | symbol = Symbol('not accessed'); + private licenseSubscription?: Subscription; private legacyAPI?: LegacyAPI; private readonly getLegacyAPI = () => { @@ -59,11 +103,23 @@ export class Plugin { return this.legacyAPI; }; + private readonly getSpacesService = () => { + // Changing property value from Symbol to undefined denotes the fact that property was accessed. + if (!this.wasSpacesServiceAccessed()) { + this.spacesService = undefined; + } + + return this.spacesService as SpacesService | undefined; + }; + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); } - public async setup(core: CoreSetup): Promise> { + public async setup( + core: CoreSetup, + { features, licensing }: PluginSetupDependencies + ): Promise> { const config = await createConfig$(this.initializerContext, core.http.isTlsEnabled) .pipe(first()) .toPromise(); @@ -72,34 +128,88 @@ export class Plugin { plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')], }); + const { license, update: updateLicense } = new SecurityLicenseService().setup(); + this.licenseSubscription = licensing.license$.subscribe(rawLicense => + updateLicense(rawLicense) + ); + const authc = await setupAuthentication({ - core, + http: core.http, + clusterClient: this.clusterClient, config, + license, + loggers: this.initializerContext.logger, + getLegacyAPI: this.getLegacyAPI, + }); + + const authz = await setupAuthorization({ + http: core.http, clusterClient: this.clusterClient, + license, loggers: this.initializerContext.logger, getLegacyAPI: this.getLegacyAPI, + packageVersion: this.initializerContext.env.packageInfo.version, + getSpacesService: this.getSpacesService, + featuresService: features, }); defineRoutes({ router: core.http.createRouter(), basePath: core.http.basePath, logger: this.initializerContext.logger.get('routes'), + clusterClient: this.clusterClient, config, authc, + authz, getLegacyAPI: this.getLegacyAPI, }); + const adminClient = await core.elasticsearch.adminClient$.pipe(first()).toPromise(); return deepFreeze({ - registerLegacyAPI: (legacyAPI: LegacyAPI) => (this.legacyAPI = legacyAPI), authc, - // We should stop exposing this config as soon as only new platform plugin consumes it. The only - // exception may be `sessionTimeout` as other parts of the app may want to know it. - config: { - sessionTimeout: config.sessionTimeout, - secureCookies: config.secureCookies, - cookieName: config.cookieName, - authc: { providers: config.authc.providers }, + authz: { + actions: authz.actions, + checkPrivilegesWithRequest: authz.checkPrivilegesWithRequest, + mode: authz.mode, + }, + + registerSpacesService: service => { + if (this.wasSpacesServiceAccessed()) { + throw new Error('Spaces service has been accessed before registration.'); + } + + this.spacesService = service; + }, + + __legacyCompat: { + registerLegacyAPI: (legacyAPI: LegacyAPI) => { + this.legacyAPI = legacyAPI; + + setupSavedObjects({ + auditLogger: new SecurityAuditLogger(legacyAPI.auditLogger), + adminClusterClient: adminClient, + authz, + legacyAPI, + }); + + legacyAPI.capabilities.registerCapabilitiesModifier((request, capabilities) => + authz.disableUnauthorizedCapabilities(KibanaRequest.from(request), capabilities) + ); + }, + + registerPrivilegesWithCluster: async () => await authz.registerPrivilegesWithCluster(), + + license, + + // We should stop exposing this config as soon as only new platform plugin consumes it. The only + // exception may be `sessionTimeout` as other parts of the app may want to know it. + config: { + sessionTimeout: config.sessionTimeout, + secureCookies: config.secureCookies, + cookieName: config.cookieName, + authc: { providers: config.authc.providers }, + }, }, }); } @@ -115,5 +225,14 @@ export class Plugin { this.clusterClient.close(); this.clusterClient = undefined; } + + if (this.licenseSubscription) { + this.licenseSubscription.unsubscribe(); + this.licenseSubscription = undefined; + } + } + + private wasSpacesServiceAccessed() { + return typeof this.spacesService !== 'symbol'; } } diff --git a/x-pack/plugins/security/server/routes/authentication/index.test.ts b/x-pack/plugins/security/server/routes/authentication/index.test.ts new file mode 100644 index 0000000000000..cad370b7837e1 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authentication/index.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { defineAuthenticationRoutes } from '.'; +import { ConfigType } from '../../config'; + +import { + elasticsearchServiceMock, + httpServiceMock, + loggingServiceMock, +} from '../../../../../../src/core/server/mocks'; +import { authenticationMock } from '../../authentication/index.mock'; +import { authorizationMock } from '../../authorization/index.mock'; + +describe('Authentication routes', () => { + it('does not register any SAML related routes if SAML auth provider is not enabled', () => { + const router = httpServiceMock.createRouter(); + + defineAuthenticationRoutes({ + router, + clusterClient: elasticsearchServiceMock.createClusterClient(), + basePath: httpServiceMock.createBasePath(), + logger: loggingServiceMock.create().get(), + config: { authc: { providers: ['basic'] } } as ConfigType, + authc: authenticationMock.create(), + authz: authorizationMock.create(), + getLegacyAPI: () => ({ cspRules: 'test-csp-rule' }), + }); + + const samlRoutePathPredicate = ([{ path }]: [{ path: string }, any]) => + path.startsWith('/api/security/saml/'); + expect(router.get.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + expect(router.post.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + expect(router.put.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + expect(router.delete.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts new file mode 100644 index 0000000000000..0e3f03255dcb9 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { defineSAMLRoutes } from './saml'; +import { RouteDefinitionParams } from '..'; + +export function defineAuthenticationRoutes(params: RouteDefinitionParams) { + if (params.config.authc.providers.includes('saml')) { + defineSAMLRoutes(params); + } +} diff --git a/x-pack/plugins/security/server/routes/authentication.test.ts b/x-pack/plugins/security/server/routes/authentication/saml.test.ts similarity index 82% rename from x-pack/plugins/security/server/routes/authentication.test.ts rename to x-pack/plugins/security/server/routes/authentication/saml.test.ts index ac677519cd937..cdef1826ddaa8 100644 --- a/x-pack/plugins/security/server/routes/authentication.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.test.ts @@ -5,18 +5,21 @@ */ import { Type } from '@kbn/config-schema'; -import { Authentication, AuthenticationResult, SAMLLoginStep } from '../authentication'; -import { defineAuthenticationRoutes } from './authentication'; +import { Authentication, AuthenticationResult, SAMLLoginStep } from '../../authentication'; +import { defineSAMLRoutes } from './saml'; +import { ConfigType } from '../../config'; +import { IRouter, RequestHandler, RouteConfig } from '../../../../../../src/core/server'; +import { LegacyAPI } from '../../plugin'; + import { + elasticsearchServiceMock, httpServerMock, httpServiceMock, loggingServiceMock, -} from '../../../../../src/core/server/mocks'; -import { ConfigType } from '../config'; -import { IRouter, RequestHandler, RouteConfig } from '../../../../../src/core/server'; -import { LegacyAPI } from '../plugin'; -import { authenticationMock } from '../authentication/index.mock'; -import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +} from '../../../../../../src/core/server/mocks'; +import { authenticationMock } from '../../authentication/index.mock'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { authorizationMock } from '../../authorization/index.mock'; describe('SAML authentication routes', () => { let router: jest.Mocked; @@ -25,35 +28,18 @@ describe('SAML authentication routes', () => { router = httpServiceMock.createRouter(); authc = authenticationMock.create(); - defineAuthenticationRoutes({ + defineSAMLRoutes({ router, + clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createBasePath(), logger: loggingServiceMock.create().get(), config: { authc: { providers: ['saml'] } } as ConfigType, authc, + authz: authorizationMock.create(), getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI), }); }); - it('does not register any SAML related routes if SAML auth provider is not enabled', () => { - const testRouter = httpServiceMock.createRouter(); - defineAuthenticationRoutes({ - router: testRouter, - basePath: httpServiceMock.createBasePath(), - logger: loggingServiceMock.create().get(), - config: { authc: { providers: ['basic'] } } as ConfigType, - authc: authenticationMock.create(), - getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI), - }); - - const samlRoutePathPredicate = ([{ path }]: [{ path: string }, any]) => - path.startsWith('/api/security/saml/'); - expect(testRouter.get.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); - expect(testRouter.post.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); - expect(testRouter.put.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); - expect(testRouter.delete.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); - }); - describe('Assertion consumer service endpoint', () => { let routeHandler: RequestHandler; let routeConfig: RouteConfig; diff --git a/x-pack/plugins/security/server/routes/authentication.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts similarity index 92% rename from x-pack/plugins/security/server/routes/authentication.ts rename to x-pack/plugins/security/server/routes/authentication/saml.ts index e0c83602afffb..61f40e583d24e 100644 --- a/x-pack/plugins/security/server/routes/authentication.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -5,19 +5,13 @@ */ import { schema } from '@kbn/config-schema'; -import { RouteDefinitionParams } from '.'; -import { SAMLLoginStep } from '../authentication'; - -export function defineAuthenticationRoutes(params: RouteDefinitionParams) { - if (params.config.authc.providers.includes('saml')) { - defineSAMLRoutes(params); - } -} +import { SAMLLoginStep } from '../../authentication'; +import { RouteDefinitionParams } from '..'; /** * Defines routes required for SAML authentication. */ -function defineSAMLRoutes({ +export function defineSAMLRoutes({ router, logger, authc, diff --git a/x-pack/plugins/security/server/routes/authorization/index.ts b/x-pack/plugins/security/server/routes/authorization/index.ts new file mode 100644 index 0000000000000..19f2bcccb04a8 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { definePrivilegesRoutes } from './privileges'; +import { defineRolesRoutes } from './roles'; +import { RouteDefinitionParams } from '..'; + +export function defineAuthorizationRoutes(params: RouteDefinitionParams) { + defineRolesRoutes(params); + definePrivilegesRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts new file mode 100644 index 0000000000000..73adaba551875 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.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 { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; +import { ILicenseCheck } from '../../../../../licensing/server'; +// TODO, require from licensing plugin root once https://github.com/elastic/kibana/pull/44922 is merged. +import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { RawKibanaPrivileges } from '../../../../common/model'; +import { defineGetPrivilegesRoutes } from './get'; + +import { httpServerMock } from '../../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../../index.mock'; + +const createRawKibanaPrivileges: () => RawKibanaPrivileges = () => { + return { + features: { + feature1: { + all: ['action1'], + }, + feature2: { + all: ['action2'], + }, + }, + space: { + all: ['space*'], + read: ['space:read'], + }, + global: { + all: ['*'], + read: ['something:/read'], + }, + reserved: { + customApplication1: ['custom-action1'], + customApplication2: ['custom-action2'], + }, + }; +}; + +interface TestOptions { + licenseCheckResult?: ILicenseCheck; + includeActions?: boolean; + asserts: { statusCode: number; result: Record }; +} + +describe('GET privileges', () => { + const getPrivilegesTest = ( + description: string, + { licenseCheckResult = { check: LICENSE_STATUS.Valid }, includeActions, asserts }: TestOptions + ) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authz.privileges.get.mockImplementation(() => + createRawKibanaPrivileges() + ); + + defineGetPrivilegesRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/api/security/privileges${includeActions ? '?includeActions=true' : ''}`, + query: includeActions ? { includeActions: 'true' } : undefined, + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + }; + + describe('failure', () => { + getPrivilegesTest(`returns result of routePreCheckLicense`, { + licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, + }); + }); + + describe('success', () => { + getPrivilegesTest(`returns registered application privileges with actions when requested`, { + includeActions: true, + asserts: { statusCode: 200, result: createRawKibanaPrivileges() }, + }); + + getPrivilegesTest(`returns registered application privileges without actions`, { + includeActions: false, + asserts: { + statusCode: 200, + result: { + global: ['all', 'read'], + space: ['all', 'read'], + features: { feature1: ['all'], feature2: ['all'] }, + reserved: ['customApplication1', 'customApplication2'], + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get.ts new file mode 100644 index 0000000000000..81047c7faea96 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get.ts @@ -0,0 +1,47 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '../..'; +import { createLicensedRouteHandler } from '../../licensed_route_handler'; + +export function defineGetPrivilegesRoutes({ router, authz }: RouteDefinitionParams) { + router.get( + { + path: '/api/security/privileges', + validate: { + query: schema.object({ + // We don't use `schema.boolean` here, because all query string parameters are treated as + // strings and @kbn/config-schema doesn't coerce strings to booleans. + includeActions: schema.maybe( + schema.oneOf([schema.literal('true'), schema.literal('false')]) + ), + }), + }, + }, + createLicensedRouteHandler((context, request, response) => { + const privileges = authz.privileges.get(); + const includeActions = request.query.includeActions === 'true'; + const privilegesResponseBody = includeActions + ? privileges + : { + global: Object.keys(privileges.global), + space: Object.keys(privileges.space), + features: Object.entries(privileges.features).reduce( + (acc, [featureId, featurePrivileges]) => { + return { + ...acc, + [featureId]: Object.keys(featurePrivileges), + }; + }, + {} + ), + reserved: Object.keys(privileges.reserved), + }; + + return response.ok({ body: privilegesResponseBody }); + }) + ); +} diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/builtin_privileges.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts similarity index 52% rename from x-pack/legacy/plugins/security/server/routes/api/v1/builtin_privileges.ts rename to x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts index 991b57b11a8f8..c9e963f0b8fc7 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/builtin_privileges.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts @@ -4,26 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; import { BuiltinESPrivileges } from '../../../../common/model'; -import { getClient } from '../../../../../../server/lib/get_client_shield'; +import { RouteDefinitionParams } from '../..'; -export function initGetBuiltinPrivilegesApi(server: Legacy.Server) { - server.route({ - method: 'GET', - path: '/api/security/v1/esPrivileges/builtin', - async handler(req: Legacy.Request) { - const callWithRequest = getClient(server).callWithRequest; - const privileges = await callWithRequest( - req, - 'shield.getBuiltinPrivileges' - ); +export function defineGetBuiltinPrivilegesRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.get( + { path: '/internal/security/esPrivileges/builtin', validate: false }, + async (context, request, response) => { + const privileges: BuiltinESPrivileges = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getBuiltinPrivileges'); // Exclude the `none` privilege, as it doesn't make sense as an option within the Kibana UI privileges.cluster = privileges.cluster.filter(privilege => privilege !== 'none'); privileges.index = privileges.index.filter(privilege => privilege !== 'none'); - return privileges; - }, - }); + return response.ok({ body: privileges }); + } + ); } diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/index.ts b/x-pack/plugins/security/server/routes/authorization/privileges/index.ts new file mode 100644 index 0000000000000..7c7ff402fcee2 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/privileges/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { RouteDefinitionParams } from '../..'; +import { defineGetPrivilegesRoutes } from './get'; +import { defineGetBuiltinPrivilegesRoutes } from './get_builtin'; + +export function definePrivilegesRoutes(params: RouteDefinitionParams) { + defineGetPrivilegesRoutes(params); + defineGetBuiltinPrivilegesRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts new file mode 100644 index 0000000000000..5699b100e3ffd --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts @@ -0,0 +1,98 @@ +/* + * 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 Boom from 'boom'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; +import { ILicenseCheck } from '../../../../../licensing/server'; +import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { defineDeleteRolesRoutes } from './delete'; + +import { + elasticsearchServiceMock, + httpServerMock, +} from '../../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../../index.mock'; + +interface TestOptions { + licenseCheckResult?: ILicenseCheck; + name: string; + apiResponse?: () => Promise; + asserts: { statusCode: number; result?: Record }; +} + +describe('DELETE role', () => { + const deleteRoleTest = ( + description: string, + { + name, + licenseCheckResult = { check: LICENSE_STATUS.Valid }, + apiResponse, + asserts, + }: TestOptions + ) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + if (apiResponse) { + mockScopedClusterClient.callAsCurrentUser.mockImplementation(apiResponse); + } + + defineDeleteRolesRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.delete.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `/api/security/role/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + if (apiResponse) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.deleteRole', + { name } + ); + } else { + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); + } + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + }; + + describe('failure', () => { + deleteRoleTest(`returns result of license checker`, { + name: 'foo-role', + licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, + }); + + const error = Boom.notFound('test not found message'); + deleteRoleTest(`returns error from cluster client`, { + name: 'foo-role', + apiResponse: () => Promise.reject(error), + asserts: { statusCode: 404, result: error }, + }); + }); + + describe('success', () => { + deleteRoleTest(`deletes role`, { + name: 'foo-role', + apiResponse: async () => {}, + asserts: { statusCode: 204, result: undefined }, + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/delete.ts b/x-pack/plugins/security/server/routes/authorization/roles/delete.ts new file mode 100644 index 0000000000000..aab815fbe449f --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/delete.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 { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '../../index'; +import { createLicensedRouteHandler } from '../../licensed_route_handler'; +import { wrapError } from '../../../errors'; + +export function defineDeleteRolesRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.delete( + { + path: '/api/security/role/{name}', + validate: { params: schema.object({ name: schema.string({ minLength: 1 }) }) }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + await clusterClient.asScoped(request).callAsCurrentUser('shield.deleteRole', { + name: request.params.name, + }); + + return response.noContent(); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts new file mode 100644 index 0000000000000..619e6e67f683b --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts @@ -0,0 +1,1157 @@ +/* + * 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 Boom from 'boom'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; +import { ILicenseCheck } from '../../../../../licensing/server'; +import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { defineGetRolesRoutes } from './get'; + +import { + elasticsearchServiceMock, + httpServerMock, +} from '../../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../../index.mock'; + +const application = 'kibana-.kibana'; +const reservedPrivilegesApplicationWildcard = 'kibana-*'; + +interface TestOptions { + name?: string; + licenseCheckResult?: ILicenseCheck; + apiResponse?: () => Promise; + asserts: { statusCode: number; result?: Record }; +} + +describe('GET role', () => { + const getRoleTest = ( + description: string, + { + name, + licenseCheckResult = { check: LICENSE_STATUS.Valid }, + apiResponse, + asserts, + }: TestOptions + ) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authz.getApplicationName.mockReturnValue(application); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + if (apiResponse) { + mockScopedClusterClient.callAsCurrentUser.mockImplementation(apiResponse); + } + + defineGetRolesRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `/api/security/role/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + if (apiResponse) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.getRole', { + name, + }); + } else { + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); + } + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + }; + + describe('failure', () => { + getRoleTest(`returns result of license check`, { + licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, + }); + + const error = Boom.notAcceptable('test not acceptable message'); + getRoleTest(`returns error from cluster client`, { + name: 'first_role', + apiResponse: () => Promise.reject(error), + asserts: { statusCode: 406, result: error }, + }); + + getRoleTest(`return error if we have empty resources`, { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['read'], + resources: [], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 500, + result: new Error("ES returned an application entry without resources, can't process this"), + }, + }); + }); + + describe('success', () => { + getRoleTest(`transforms elasticsearch privileges`, { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: ['manage_watcher'], + indices: [ + { + names: ['.kibana*'], + privileges: ['read', 'view_index_metadata'], + }, + ], + applications: [], + run_as: ['other_user'], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: ['manage_watcher'], + indices: [ + { + names: ['.kibana*'], + privileges: ['read', 'view_index_metadata'], + }, + ], + run_as: ['other_user'], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + }); + + describe('global', () => { + getRoleTest( + `transforms matching applications with * resource to kibana global base privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['all', 'read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: ['all', 'read'], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `transforms matching applications with * resource to kibana global feature privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + ], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + foo: ['foo-privilege-1', 'foo-privilege-2'], + bar: ['bar-privilege-1'], + }, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `transforms matching applications with * resource to kibana _reserved privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_customApplication1', 'reserved_customApplication2'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + _reserved: ['customApplication1', 'customApplication2'], + base: [], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `transforms applications with wildcard and * resource to kibana _reserved privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['reserved_customApplication1', 'reserved_customApplication2'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + _reserved: ['customApplication1', 'customApplication2'], + base: [], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + } + ); + }); + + describe('space', () => { + getRoleTest( + `transforms matching applications with space resources to kibana space base privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all', 'space_read'], + resources: ['space:marketing', 'space:sales'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: ['all', 'read'], + feature: {}, + spaces: ['marketing', 'sales'], + }, + { + base: ['read'], + feature: {}, + spaces: ['engineering'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `transforms matching applications with space resources to kibana space feature privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + ], + resources: ['space:marketing', 'space:sales'], + }, + { + application, + privileges: ['feature_foo.foo-privilege-1'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + foo: ['foo-privilege-1', 'foo-privilege-2'], + bar: ['bar-privilege-1'], + }, + spaces: ['marketing', 'sales'], + }, + { + base: [], + feature: { + foo: ['foo-privilege-1'], + }, + spaces: ['engineering'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + } + ); + }); + + getRoleTest( + `resource not * without space: prefix returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['read'], + resources: ['default'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `* and a space in the same entry returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['read'], + resources: ['default'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `* appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all'], + resources: ['space:engineering'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `space privilege assigned globally returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all'], + resources: ['*'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `space privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `global base privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['all'], + resources: ['space:marketing'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `global base privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['all'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `reserved privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo'], + resources: ['space:marketing'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `reserved privilege assigned with a base privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo', 'read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `reserved privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo', 'feature_foo.foo-privilege-1'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `global base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['all', 'feature_foo.foo-privilege-1'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest( + `space base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all', 'feature_foo.foo-privilege-1'], + resources: ['space:space_1'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + } + ); + + getRoleTest(`transforms unrecognized applications`, { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.another-kibana', + privileges: ['read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: ['kibana-.another-kibana'], + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.ts new file mode 100644 index 0000000000000..be69e222dd093 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.ts @@ -0,0 +1,46 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '../..'; +import { createLicensedRouteHandler } from '../../licensed_route_handler'; +import { wrapError } from '../../../errors'; +import { transformElasticsearchRoleToRole } from './model'; + +export function defineGetRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) { + router.get( + { + path: '/api/security/role/{name}', + validate: { params: schema.object({ name: schema.string({ minLength: 1 }) }) }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const elasticsearchRoles = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getRole', { name: request.params.name }); + + const elasticsearchRole = elasticsearchRoles[request.params.name]; + if (elasticsearchRole) { + return response.ok({ + body: transformElasticsearchRoleToRole( + elasticsearchRole, + request.params.name, + authz.getApplicationName() + ), + }); + } + + return response.notFound(); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts new file mode 100644 index 0000000000000..d04513592f027 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts @@ -0,0 +1,1335 @@ +/* + * 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 Boom from 'boom'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; +import { ILicenseCheck } from '../../../../../licensing/server'; +import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { defineGetAllRolesRoutes } from './get_all'; + +import { + elasticsearchServiceMock, + httpServerMock, +} from '../../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../../index.mock'; + +const application = 'kibana-.kibana'; +const reservedPrivilegesApplicationWildcard = 'kibana-*'; + +interface TestOptions { + name?: string; + licenseCheckResult?: ILicenseCheck; + apiResponse?: () => Promise; + asserts: { statusCode: number; result?: Record }; +} + +describe('GET all roles', () => { + const getRolesTest = ( + description: string, + { licenseCheckResult = { check: LICENSE_STATUS.Valid }, apiResponse, asserts }: TestOptions + ) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authz.getApplicationName.mockReturnValue(application); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + if (apiResponse) { + mockScopedClusterClient.callAsCurrentUser.mockImplementation(apiResponse); + } + + defineGetAllRolesRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: '/api/security/role', + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + if (apiResponse) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.getRole'); + } else { + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); + } + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + }; + + describe('failure', () => { + getRolesTest(`returns result of license check`, { + licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, + }); + + const error = Boom.notAcceptable('test not acceptable message'); + getRolesTest(`returns error from cluster client`, { + apiResponse: () => Promise.reject(error), + asserts: { statusCode: 406, result: error }, + }); + + getRolesTest(`return error if we have empty resources`, { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['read'], + resources: [], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 500, + result: new Error("ES returned an application entry without resources, can't process this"), + }, + }); + }); + + describe('success', () => { + getRolesTest(`transforms elasticsearch privileges`, { + apiResponse: async () => ({ + first_role: { + cluster: ['manage_watcher'], + indices: [ + { + names: ['.kibana*'], + privileges: ['read', 'view_index_metadata'], + }, + ], + applications: [], + run_as: ['other_user'], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: ['manage_watcher'], + indices: [ + { + names: ['.kibana*'], + privileges: ['read', 'view_index_metadata'], + }, + ], + run_as: ['other_user'], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + }); + + describe('global', () => { + getRolesTest( + `transforms matching applications with * resource to kibana global base privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['all', 'read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: ['all', 'read'], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `transforms matching applications with * resource to kibana global feature privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + ], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + foo: ['foo-privilege-1', 'foo-privilege-2'], + bar: ['bar-privilege-1'], + }, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `transforms matching applications with * resource to kibana _reserved privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_customApplication1', 'reserved_customApplication2'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + _reserved: ['customApplication1', 'customApplication2'], + base: [], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `transforms applications with wildcard and * resource to kibana _reserved privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['reserved_customApplication1', 'reserved_customApplication2'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + _reserved: ['customApplication1', 'customApplication2'], + base: [], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + } + ); + }); + + describe('space', () => { + getRolesTest( + `transforms matching applications with space resources to kibana space base privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all', 'space_read'], + resources: ['space:marketing', 'space:sales'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: ['all', 'read'], + feature: {}, + spaces: ['marketing', 'sales'], + }, + { + base: ['read'], + feature: {}, + spaces: ['engineering'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `transforms matching applications with space resources to kibana space feature privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + ], + resources: ['space:marketing', 'space:sales'], + }, + { + application, + privileges: ['feature_foo.foo-privilege-1'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + foo: ['foo-privilege-1', 'foo-privilege-2'], + bar: ['bar-privilege-1'], + }, + spaces: ['marketing', 'sales'], + }, + { + base: [], + feature: { + foo: ['foo-privilege-1'], + }, + spaces: ['engineering'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + } + ); + }); + + getRolesTest( + `resource not * without space: prefix returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['read'], + resources: ['default'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `* and a space in the same entry returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['all'], + resources: ['*', 'space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `* appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['all'], + resources: ['*'], + }, + { + application, + privileges: ['read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `space appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all'], + resources: ['space:engineering'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `space privilege assigned globally returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all'], + resources: ['*'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `space privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `global base privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['all'], + resources: ['space:marketing'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `global base privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['all'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `reserved privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo'], + resources: ['space:marketing'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `reserved privilege assigned with a base privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo', 'read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `reserved privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo', 'feature_foo.foo-privilege-1'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `global base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['all', 'feature_foo.foo-privilege-1'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest( + `space base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all', 'feature_foo.foo-privilege-1'], + resources: ['space:space_1'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, + } + ); + + getRolesTest(`transforms unrecognized applications`, { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.another-kibana', + privileges: ['read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: ['kibana-.another-kibana'], + }, + ], + }, + }); + + getRolesTest(`returns a sorted list of roles`, { + apiResponse: async () => ({ + z_role: { + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.another-kibana', + privileges: ['read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + a_role: { + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.another-kibana', + privileges: ['read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + b_role: { + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.another-kibana', + privileges: ['read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'a_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: ['kibana-.another-kibana'], + }, + { + name: 'b_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: ['kibana-.another-kibana'], + }, + { + name: 'z_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: ['kibana-.another-kibana'], + }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts new file mode 100644 index 0000000000000..f5d2d51280fc4 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RouteDefinitionParams } from '../..'; +import { createLicensedRouteHandler } from '../../licensed_route_handler'; +import { wrapError } from '../../../errors'; +import { ElasticsearchRole, transformElasticsearchRoleToRole } from './model'; + +export function defineGetAllRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) { + router.get( + { path: '/api/security/role', validate: false }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const elasticsearchRoles = (await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getRole')) as Record; + + // Transform elasticsearch roles into Kibana roles and return in a list sorted by the role name. + return response.ok({ + body: Object.entries(elasticsearchRoles) + .map(([roleName, elasticsearchRole]) => + transformElasticsearchRoleToRole( + elasticsearchRole, + roleName, + authz.getApplicationName() + ) + ) + .sort((roleA, roleB) => { + if (roleA.name < roleB.name) { + return -1; + } + + if (roleA.name > roleB.name) { + return 1; + } + + return 0; + }), + }); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/authorization/roles/index.ts b/x-pack/plugins/security/server/routes/authorization/roles/index.ts new file mode 100644 index 0000000000000..39cb31b9d20f3 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/index.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 { RouteDefinitionParams } from '../..'; +import { defineGetRolesRoutes } from './get'; +import { defineGetAllRolesRoutes } from './get_all'; +import { defineDeleteRolesRoutes } from './delete'; +import { definePutRolesRoutes } from './put'; + +export function defineRolesRoutes(params: RouteDefinitionParams) { + defineGetRolesRoutes(params); + defineGetAllRolesRoutes(params); + defineDeleteRolesRoutes(params); + definePutRolesRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts new file mode 100644 index 0000000000000..c590c24923a8c --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts @@ -0,0 +1,274 @@ +/* + * 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 { + GLOBAL_RESOURCE, + RESERVED_PRIVILEGES_APPLICATION_WILDCARD, +} from '../../../../../common/constants'; +import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer'; +import { ResourceSerializer } from '../../../../authorization/resource_serializer'; + +export type ElasticsearchRole = Pick & { + applications: Array<{ + application: string; + privileges: string[]; + resources: string[]; + }>; + cluster: Role['elasticsearch']['cluster']; + indices: Role['elasticsearch']['indices']; + run_as: Role['elasticsearch']['run_as']; +}; + +export function transformElasticsearchRoleToRole( + elasticsearchRole: ElasticsearchRole, + name: string, + application: string +): Role { + const kibanaTransformResult = transformRoleApplicationsToKibanaPrivileges( + elasticsearchRole.applications, + application + ); + + return { + name, + metadata: elasticsearchRole.metadata, + transient_metadata: elasticsearchRole.transient_metadata, + elasticsearch: { + cluster: elasticsearchRole.cluster, + indices: elasticsearchRole.indices, + run_as: elasticsearchRole.run_as, + }, + kibana: kibanaTransformResult.success ? (kibanaTransformResult.value as Role['kibana']) : [], + _transform_error: [...(kibanaTransformResult.success ? [] : ['kibana'])], + _unrecognized_applications: extractUnrecognizedApplicationNames( + elasticsearchRole.applications, + application + ), + }; +} + +function transformRoleApplicationsToKibanaPrivileges( + roleApplications: ElasticsearchRole['applications'], + application: string +) { + const roleKibanaApplications = roleApplications.filter( + roleApplication => + roleApplication.application === application || + roleApplication.application === RESERVED_PRIVILEGES_APPLICATION_WILDCARD + ); + + // if any application entry contains an empty resource, we throw an error + if (roleKibanaApplications.some(entry => entry.resources.length === 0)) { + throw new Error(`ES returned an application entry without resources, can't process this`); + } + + // if there is an entry with the reserved privileges application wildcard + // and there are privileges which aren't reserved, we won't transform these + if ( + roleKibanaApplications.some( + entry => + entry.application === RESERVED_PRIVILEGES_APPLICATION_WILDCARD && + !entry.privileges.every(privilege => + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + ) + ) + ) { + return { + success: false, + }; + } + + // if space privilege assigned globally, we can't transform these + if ( + roleKibanaApplications.some( + entry => + entry.resources.includes(GLOBAL_RESOURCE) && + entry.privileges.some(privilege => + PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) + ) + ) + ) { + return { + success: false, + }; + } + + // if global base or reserved privilege assigned at a space, we can't transform these + if ( + roleKibanaApplications.some( + entry => + !entry.resources.includes(GLOBAL_RESOURCE) && + entry.privileges.some( + privilege => + PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) || + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + ) + ) + ) { + return { + success: false, + }; + } + + // if reserved privilege assigned with feature or base privileges, we won't transform these + if ( + roleKibanaApplications.some( + entry => + entry.privileges.some(privilege => + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + ) && + entry.privileges.some( + privilege => !PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + ) + ) + ) { + return { + success: false, + }; + } + + // if base privilege assigned with feature privileges, we won't transform these + if ( + roleKibanaApplications.some( + entry => + entry.privileges.some(privilege => + PrivilegeSerializer.isSerializedFeaturePrivilege(privilege) + ) && + (entry.privileges.some(privilege => + PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) + ) || + entry.privileges.some(privilege => + PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) + )) + ) + ) { + return { + success: false, + }; + } + + // if any application entry contains the '*' resource in addition to another resource, we can't transform these + if ( + roleKibanaApplications.some( + entry => entry.resources.includes(GLOBAL_RESOURCE) && entry.resources.length > 1 + ) + ) { + return { + success: false, + }; + } + + const allResources = roleKibanaApplications.map(entry => entry.resources).flat(); + // if we have improperly formatted resource entries, we can't transform these + if ( + allResources.some( + resource => + resource !== GLOBAL_RESOURCE && !ResourceSerializer.isSerializedSpaceResource(resource) + ) + ) { + return { + success: false, + }; + } + + // if we have resources duplicated in entries, we won't transform these + if (allResources.length !== getUniqueList(allResources).length) { + return { + success: false, + }; + } + + return { + success: true, + value: roleKibanaApplications.map(({ resources, privileges }) => { + // if we're dealing with a global entry, which we've ensured above is only possible if it's the only item in the array + if (resources.length === 1 && resources[0] === GLOBAL_RESOURCE) { + const reservedPrivileges = privileges.filter(privilege => + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + ); + const basePrivileges = privileges.filter(privilege => + PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) + ); + const featurePrivileges = privileges.filter(privilege => + PrivilegeSerializer.isSerializedFeaturePrivilege(privilege) + ); + + return { + ...(reservedPrivileges.length + ? { + _reserved: reservedPrivileges.map(privilege => + PrivilegeSerializer.deserializeReservedPrivilege(privilege) + ), + } + : {}), + base: basePrivileges.map(privilege => + PrivilegeSerializer.serializeGlobalBasePrivilege(privilege) + ), + feature: featurePrivileges.reduce( + (acc, privilege) => { + const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege); + return { + ...acc, + [featurePrivilege.featureId]: getUniqueList([ + ...(acc[featurePrivilege.featureId] || []), + featurePrivilege.privilege, + ]), + }; + }, + {} as RoleKibanaPrivilege['feature'] + ), + spaces: ['*'], + }; + } + + const basePrivileges = privileges.filter(privilege => + PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) + ); + const featurePrivileges = privileges.filter(privilege => + PrivilegeSerializer.isSerializedFeaturePrivilege(privilege) + ); + return { + base: basePrivileges.map(privilege => + PrivilegeSerializer.deserializeSpaceBasePrivilege(privilege) + ), + feature: featurePrivileges.reduce( + (acc, privilege) => { + const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege); + return { + ...acc, + [featurePrivilege.featureId]: getUniqueList([ + ...(acc[featurePrivilege.featureId] || []), + featurePrivilege.privilege, + ]), + }; + }, + {} as RoleKibanaPrivilege['feature'] + ), + spaces: resources.map(resource => ResourceSerializer.deserializeSpaceResource(resource)), + }; + }), + }; +} + +const extractUnrecognizedApplicationNames = ( + roleApplications: ElasticsearchRole['applications'], + application: string +) => { + return getUniqueList( + roleApplications + .filter( + roleApplication => + roleApplication.application !== application && + roleApplication.application !== RESERVED_PRIVILEGES_APPLICATION_WILDCARD + ) + .map(roleApplication => roleApplication.application) + ); +}; + +function getUniqueList(list: T[]) { + return Array.from(new Set(list)); +} diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts new file mode 100644 index 0000000000000..8cf4956c2ac17 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { ElasticsearchRole, transformElasticsearchRoleToRole } from './elasticsearch_role'; +export { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './put_payload'; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts new file mode 100644 index 0000000000000..e9ba5c41c3988 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts @@ -0,0 +1,346 @@ +/* + * 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 { getPutPayloadSchema } from './put_payload'; + +const basePrivilegeNamesMap = { + global: ['all', 'read'], + space: ['all', 'read'], +}; + +describe('Put payload schema', () => { + test('only allows features that match the pattern', () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ feature: { '!foo': ['foo'] } }], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[kibana.0.feature.key(\\"!foo\\")]: only a-z, A-Z, 0-9, '_', and '-' are allowed"` + ); + }); + + test('only allows feature privileges that match the pattern', () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ feature: { foo: ['!foo'] } }], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[kibana.0.feature.foo]: only a-z, A-Z, 0-9, '_', and '-' are allowed"` + ); + }); + + test('requires either base or feature', () => { + for (const kibanaPrivilege of [ + {}, + { base: [] }, + { feature: {} }, + { feature: { foo: [], bar: [] } }, + { base: [], feature: {} }, + { base: [], feature: { foo: [], bar: [] } }, + ]) { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [kibanaPrivilege], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[kibana.0]: either [base] or [feature] is expected, but none of them specified"` + ); + } + }); + + test(`doesn't allow both base and feature in the same entry`, () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ base: ['all'], feature: { foo: ['foo'] } }], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[kibana.0]: definition of [feature] isn't allowed when non-empty [base] is defined."` + ); + }); + + describe('global', () => { + test(`only allows known Kibana global base privileges`, () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ base: ['foo'], spaces: ['*'] }], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[kibana.0.base.0]: unknown global privilege \\"foo\\", must be one of [all,read]"` + ); + }); + + test(`doesn't allow Kibana reserved privileges`, () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ _reserved: ['customApplication1'], spaces: ['*'] }], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[kibana.0._reserved]: definition for this key is missing"` + ); + }); + + test(`only allows one global entry`, () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [ + { feature: { foo: ['foo-privilege-1'] }, spaces: ['*'] }, + { feature: { bar: ['bar-privilege-1'] }, spaces: ['*'] }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[kibana]: more than one privilege is applied to the following spaces: [*]"` + ); + }); + }); + + describe('space', () => { + test(`doesn't allow * in a space ID`, () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ spaces: ['foo-*'] }], + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[kibana.0.spaces]: types that failed validation: +- [kibana.0.spaces.0.0]: expected value to equal [*] but got [foo-*] +- [kibana.0.spaces.1.0]: must be lower case, a-z, 0-9, '_', and '-' are allowed" +`); + }); + + test(`can't assign space and global in same entry`, () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ spaces: ['*', 'foo-space'] }], + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[kibana.0.spaces]: types that failed validation: +- [kibana.0.spaces.0.1]: expected value to equal [*] but got [foo-space] +- [kibana.0.spaces.1.0]: must be lower case, a-z, 0-9, '_', and '-' are allowed" +`); + }); + + test(`only allows known Kibana space base privileges`, () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ base: ['foo'], spaces: ['foo-space'] }], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[kibana.0.base.0]: unknown space privilege \\"foo\\", must be one of [all,read]"` + ); + }); + + test(`only allows space to be in one entry`, () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [ + { feature: { foo: ['foo-privilege-1'] }, spaces: ['marketing'] }, + { feature: { bar: ['bar-privilege-1'] }, spaces: ['sales', 'marketing'] }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[kibana]: more than one privilege is applied to the following spaces: [marketing]"` + ); + }); + + test(`doesn't allow Kibana reserved privileges`, () => { + expect(() => + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ _reserved: ['customApplication1'], spaces: ['marketing'] }], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[kibana.0._reserved]: definition for this key is missing"` + ); + }); + }); + + test('allows empty role', () => { + expect(getPutPayloadSchema(() => basePrivilegeNamesMap).validate({})).toMatchInlineSnapshot(` + Object { + "elasticsearch": Object {}, + } + `); + }); + + test('if spaces is not specified, defaults to global', () => { + expect( + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ base: ['all'] }], + }) + ).toMatchInlineSnapshot(` + Object { + "elasticsearch": Object {}, + "kibana": Array [ + Object { + "base": Array [ + "all", + ], + "spaces": Array [ + "*", + ], + }, + ], + } + `); + }); + + test('allows base with empty array and feature in the same entry', () => { + expect( + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ base: [], feature: { foo: ['foo'] } }], + }) + ).toMatchInlineSnapshot(` + Object { + "elasticsearch": Object {}, + "kibana": Array [ + Object { + "base": Array [], + "feature": Object { + "foo": Array [ + "foo", + ], + }, + "spaces": Array [ + "*", + ], + }, + ], + } + `); + }); + + test('allows base and feature with empty object in the same entry', () => { + expect( + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + kibana: [{ base: ['all'], feature: {} }], + }) + ).toMatchInlineSnapshot(` + Object { + "elasticsearch": Object {}, + "kibana": Array [ + Object { + "base": Array [ + "all", + ], + "feature": Object {}, + "spaces": Array [ + "*", + ], + }, + ], + } + `); + }); + + test('allows full set of fields', () => { + expect( + getPutPayloadSchema(() => basePrivilegeNamesMap).validate({ + metadata: { + foo: 'test-metadata', + }, + elasticsearch: { + cluster: ['test-cluster-privilege'], + indices: [ + { + field_security: { + grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], + except: ['test-field-security-except-1', 'test-field-security-except-2'], + }, + names: ['test-index-name-1', 'test-index-name-2'], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], + query: `{ "match": { "title": "foo" } }`, + }, + ], + run_as: ['test-run-as-1', 'test-run-as-2'], + }, + kibana: [ + { + base: ['all', 'read'], + spaces: ['*'], + }, + { + base: ['all', 'read'], + spaces: ['test-space-1', 'test-space-2'], + }, + { + feature: { + foo: ['foo-privilege-1', 'foo-privilege-2'], + }, + spaces: ['test-space-3'], + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "elasticsearch": Object { + "cluster": Array [ + "test-cluster-privilege", + ], + "indices": Array [ + Object { + "field_security": Object { + "except": Array [ + "test-field-security-except-1", + "test-field-security-except-2", + ], + "grant": Array [ + "test-field-security-grant-1", + "test-field-security-grant-2", + ], + }, + "names": Array [ + "test-index-name-1", + "test-index-name-2", + ], + "privileges": Array [ + "test-index-privilege-1", + "test-index-privilege-2", + ], + "query": "{ \\"match\\": { \\"title\\": \\"foo\\" } }", + }, + ], + "run_as": Array [ + "test-run-as-1", + "test-run-as-2", + ], + }, + "kibana": Array [ + Object { + "base": Array [ + "all", + "read", + ], + "spaces": Array [ + "*", + ], + }, + Object { + "base": Array [ + "all", + "read", + ], + "spaces": Array [ + "test-space-1", + "test-space-2", + ], + }, + Object { + "feature": Object { + "foo": Array [ + "foo-privilege-1", + "foo-privilege-2", + ], + }, + "spaces": Array [ + "test-space-3", + ], + }, + ], + "metadata": Object { + "foo": "test-metadata", + }, + } + `); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts new file mode 100644 index 0000000000000..a5f6b2fd9fcc1 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts @@ -0,0 +1,300 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; +import { GLOBAL_RESOURCE } from '../../../../../common/constants'; +import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer'; +import { ResourceSerializer } from '../../../../authorization/resource_serializer'; +import { ElasticsearchRole } from './elasticsearch_role'; + +/** + * Elasticsearch specific portion of the role definition. + * See more details at https://www.elastic.co/guide/en/elasticsearch/reference/master/security-api.html#security-role-apis. + */ +const elasticsearchRoleSchema = schema.object({ + /** + * An optional list of cluster privileges. These privileges define the cluster level actions that + * users with this role are able to execute + */ + cluster: schema.maybe(schema.arrayOf(schema.string())), + + /** + * An optional list of indices permissions entries. + */ + indices: schema.maybe( + schema.arrayOf( + schema.object({ + /** + * Required list of indices (or index name patterns) to which the permissions in this + * entry apply. + */ + names: schema.arrayOf(schema.string(), { minSize: 1 }), + + /** + * An optional set of the document fields that the owners of the role have read access to. + */ + field_security: schema.maybe( + schema.recordOf( + schema.oneOf([schema.literal('grant'), schema.literal('except')]), + schema.arrayOf(schema.string()) + ) + ), + + /** + * Required list of the index level privileges that the owners of the role have on the + * specified indices. + */ + privileges: schema.arrayOf(schema.string(), { minSize: 1 }), + + /** + * An optional search query that defines the documents the owners of the role have read access + * to. A document within the specified indices must match this query in order for it to be + * accessible by the owners of the role. + */ + query: schema.maybe(schema.string()), + + /** + * An optional flag used to indicate if index pattern wildcards or regexps should cover + * restricted indices. + */ + allow_restricted_indices: schema.maybe(schema.boolean()), + }) + ) + ), + + /** + * An optional list of users that the owners of this role can impersonate. + */ + run_as: schema.maybe(schema.arrayOf(schema.string())), +}); + +const allSpacesSchema = schema.arrayOf(schema.literal(GLOBAL_RESOURCE), { + minSize: 1, + maxSize: 1, +}); + +/** + * Schema for the list of space IDs used within Kibana specific role definition. + */ +const spacesSchema = schema.oneOf( + [ + allSpacesSchema, + schema.arrayOf( + schema.string({ + validate(value) { + if (!/^[a-z0-9_-]+$/.test(value)) { + return `must be lower case, a-z, 0-9, '_', and '-' are allowed`; + } + }, + }) + ), + ], + { defaultValue: [GLOBAL_RESOURCE] } +); + +const FEATURE_NAME_VALUE_REGEX = /^[a-zA-Z0-9_-]+$/; + +type PutPayloadSchemaType = TypeOf>; +export function getPutPayloadSchema( + getBasePrivilegeNames: () => { global: string[]; space: string[] } +) { + return schema.object({ + /** + * An optional meta-data dictionary. Within the metadata, keys that begin with _ are reserved + * for system usage. + */ + metadata: schema.maybe(schema.recordOf(schema.string(), schema.any())), + + /** + * Elasticsearch specific portion of the role definition. + */ + elasticsearch: elasticsearchRoleSchema, + + /** + * Kibana specific portion of the role definition. It's represented as a list of base and/or + * feature Kibana privileges. None of the entries should apply to the same spaces. + */ + kibana: schema.maybe( + schema.arrayOf( + schema.object( + { + /** + * An optional list of space IDs to which the permissions in this entry apply. If not + * specified it defaults to special "global" space ID (all spaces). + */ + spaces: spacesSchema, + + /** + * An optional list of Kibana base privileges. If this entry applies to special "global" + * space (all spaces) then specified base privileges should be within known base "global" + * privilege list, otherwise - within known "space" privilege list. Base privileges + * definition isn't allowed when feature privileges are defined and required otherwise. + */ + base: schema.maybe( + schema.conditional( + schema.siblingRef('spaces'), + allSpacesSchema, + schema.arrayOf( + schema.string({ + validate(value) { + const globalPrivileges = getBasePrivilegeNames().global; + if (!globalPrivileges.some(privilege => privilege === value)) { + return `unknown global privilege "${value}", must be one of [${globalPrivileges}]`; + } + }, + }) + ), + schema.arrayOf( + schema.string({ + validate(value) { + const spacePrivileges = getBasePrivilegeNames().space; + if (!spacePrivileges.some(privilege => privilege === value)) { + return `unknown space privilege "${value}", must be one of [${spacePrivileges}]`; + } + }, + }) + ) + ) + ), + + /** + * An optional dictionary of Kibana feature privileges where the key is the ID of the + * feature and the value is a list of feature specific privilege IDs. Both feature and + * privilege IDs should consist of allowed set of characters. Feature privileges + * definition isn't allowed when base privileges are defined and required otherwise. + */ + feature: schema.maybe( + schema.recordOf( + schema.string({ + validate(value) { + if (!FEATURE_NAME_VALUE_REGEX.test(value)) { + return `only a-z, A-Z, 0-9, '_', and '-' are allowed`; + } + }, + }), + schema.arrayOf( + schema.string({ + validate(value) { + if (!FEATURE_NAME_VALUE_REGEX.test(value)) { + return `only a-z, A-Z, 0-9, '_', and '-' are allowed`; + } + }, + }) + ) + ) + ), + }, + { + validate(value) { + if ( + (value.base === undefined || value.base.length === 0) && + (value.feature === undefined || Object.values(value.feature).flat().length === 0) + ) { + return 'either [base] or [feature] is expected, but none of them specified'; + } + + if ( + value.base !== undefined && + value.base.length > 0 && + value.feature !== undefined && + Object.keys(value.feature).length > 0 + ) { + return `definition of [feature] isn't allowed when non-empty [base] is defined.`; + } + }, + } + ), + { + validate(value) { + for (const [indexA, valueA] of value.entries()) { + for (const valueB of value.slice(indexA + 1)) { + const spaceIntersection = _.intersection(valueA.spaces, valueB.spaces); + if (spaceIntersection.length !== 0) { + return `more than one privilege is applied to the following spaces: [${spaceIntersection}]`; + } + } + } + }, + } + ) + ), + }); +} + +export const transformPutPayloadToElasticsearchRole = ( + rolePayload: PutPayloadSchemaType, + application: string, + allExistingApplications: ElasticsearchRole['applications'] = [] +) => { + const { + elasticsearch = { cluster: undefined, indices: undefined, run_as: undefined }, + kibana = [], + } = rolePayload; + const otherApplications = allExistingApplications.filter( + roleApplication => roleApplication.application !== application + ); + + return { + metadata: rolePayload.metadata, + cluster: elasticsearch.cluster || [], + indices: elasticsearch.indices || [], + run_as: elasticsearch.run_as || [], + applications: [ + ...transformPrivilegesToElasticsearchPrivileges(application, kibana), + ...otherApplications, + ], + } as Omit; +}; + +const transformPrivilegesToElasticsearchPrivileges = ( + application: string, + kibanaPrivileges: PutPayloadSchemaType['kibana'] = [] +) => { + return kibanaPrivileges.map(({ base, feature, spaces }) => { + if (spaces.length === 1 && spaces[0] === GLOBAL_RESOURCE) { + return { + privileges: [ + ...(base + ? base.map(privilege => PrivilegeSerializer.serializeGlobalBasePrivilege(privilege)) + : []), + ...(feature + ? Object.entries(feature) + .map(([featureName, featurePrivileges]) => + featurePrivileges.map(privilege => + PrivilegeSerializer.serializeFeaturePrivilege(featureName, privilege) + ) + ) + .flat() + : []), + ], + application, + resources: [GLOBAL_RESOURCE], + }; + } + + return { + privileges: [ + ...(base + ? base.map(privilege => PrivilegeSerializer.serializeSpaceBasePrivilege(privilege)) + : []), + ...(feature + ? Object.entries(feature) + .map(([featureName, featurePrivileges]) => + featurePrivileges.map(privilege => + PrivilegeSerializer.serializeFeaturePrivilege(featureName, privilege) + ) + ) + .flat() + : []), + ], + application, + resources: (spaces as string[]).map(resource => + ResourceSerializer.serializeSpaceResource(resource) + ), + }; + }); +}; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts new file mode 100644 index 0000000000000..fa4f2350bb7dd --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -0,0 +1,603 @@ +/* + * 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 { Type } from '@kbn/config-schema'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; +import { ILicenseCheck } from '../../../../../licensing/server'; +import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { GLOBAL_RESOURCE } from '../../../../common/constants'; +import { definePutRolesRoutes } from './put'; + +import { + elasticsearchServiceMock, + httpServerMock, +} from '../../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../../index.mock'; + +const application = 'kibana-.kibana'; +const privilegeMap = { + global: { + all: [], + read: [], + }, + space: { + all: [], + read: [], + }, + features: { + foo: { + 'foo-privilege-1': [], + 'foo-privilege-2': [], + }, + bar: { + 'bar-privilege-1': [], + 'bar-privilege-2': [], + }, + }, + reserved: { + customApplication1: [], + customApplication2: [], + }, +}; + +interface TestOptions { + name: string; + licenseCheckResult?: ILicenseCheck; + apiResponses?: Array<() => Promise>; + payload?: Record; + asserts: { statusCode: number; result?: Record; apiArguments?: unknown[][] }; +} + +const putRoleTest = ( + description: string, + { + name, + payload, + licenseCheckResult = { check: LICENSE_STATUS.Valid }, + apiResponses = [], + asserts, + }: TestOptions +) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authz.getApplicationName.mockReturnValue(application); + mockRouteDefinitionParams.authz.privileges.get.mockReturnValue(privilegeMap); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + for (const apiResponse of apiResponses) { + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(apiResponse); + } + + definePutRolesRoutes(mockRouteDefinitionParams); + const [[{ validate }, handler]] = mockRouteDefinitionParams.router.put.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'put', + path: `/api/security/role/${name}`, + params: { name }, + body: payload !== undefined ? (validate as any).body.validate(payload) : undefined, + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + if (Array.isArray(asserts.apiArguments)) { + for (const apiArguments of asserts.apiArguments) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(...apiArguments); + } + } else { + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); + } + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); +}; + +describe('PUT role', () => { + describe('request validation', () => { + let requestParamsSchema: Type; + beforeEach(() => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authz.privileges.get.mockReturnValue(privilegeMap); + definePutRolesRoutes(mockRouteDefinitionParams); + + const [[{ validate }]] = mockRouteDefinitionParams.router.put.mock.calls; + requestParamsSchema = (validate as any).params; + }); + + test('requires name in params', () => { + expect(() => + requestParamsSchema.validate({}, {}, 'request params') + ).toThrowErrorMatchingInlineSnapshot( + `"[request params.name]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + requestParamsSchema.validate({ name: '' }, {}, 'request params') + ).toThrowErrorMatchingInlineSnapshot( + `"[request params.name]: value is [] but it must have a minimum length of [1]."` + ); + }); + + test('requires name in params to not exceed 1024 characters', () => { + expect(() => + requestParamsSchema.validate({ name: 'a'.repeat(1025) }, {}, 'request params') + ).toThrowErrorMatchingInlineSnapshot( + `"[request params.name]: value is [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa] but it must have a maximum length of [1024]."` + ); + }); + }); + + describe('failure', () => { + putRoleTest(`returns result of license checker`, { + name: 'foo-role', + licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, + }); + }); + + describe('success', () => { + putRoleTest(`creates empty role`, { + name: 'foo-role', + payload: {}, + apiResponses: [async () => ({}), async () => {}], + asserts: { + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + cluster: [], + indices: [], + run_as: [], + applications: [], + }, + }, + ], + ], + statusCode: 204, + result: undefined, + }, + }); + + putRoleTest(`if spaces isn't specified, defaults to global`, { + name: 'foo-role', + payload: { + kibana: [ + { + base: ['all'], + }, + ], + }, + apiResponses: [async () => ({}), async () => {}], + asserts: { + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + cluster: [], + indices: [], + run_as: [], + applications: [ + { + application, + privileges: ['all'], + resources: [GLOBAL_RESOURCE], + }, + ], + }, + }, + ], + ], + statusCode: 204, + result: undefined, + }, + }); + + putRoleTest(`allows base with empty array and feature in the same entry`, { + name: 'foo-role', + payload: { + kibana: [ + { + base: [], + feature: { + foo: ['foo'], + }, + }, + ], + }, + apiResponses: [async () => ({}), async () => {}], + asserts: { + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + cluster: [], + indices: [], + run_as: [], + applications: [ + { + application, + privileges: ['feature_foo.foo'], + resources: [GLOBAL_RESOURCE], + }, + ], + }, + }, + ], + ], + statusCode: 204, + result: undefined, + }, + }); + + putRoleTest(`allows base and feature with empty object in the same entry`, { + name: 'foo-role', + payload: { + kibana: [ + { + base: ['all'], + feature: {}, + }, + ], + }, + apiResponses: [async () => ({}), async () => {}], + asserts: { + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + cluster: [], + indices: [], + run_as: [], + applications: [ + { + application, + privileges: ['all'], + resources: [GLOBAL_RESOURCE], + }, + ], + }, + }, + ], + ], + statusCode: 204, + result: undefined, + }, + }); + + putRoleTest(`creates role with everything`, { + name: 'foo-role', + payload: { + metadata: { + foo: 'test-metadata', + }, + elasticsearch: { + cluster: ['test-cluster-privilege'], + indices: [ + { + field_security: { + grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], + except: ['test-field-security-except-1', 'test-field-security-except-2'], + }, + names: ['test-index-name-1', 'test-index-name-2'], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], + query: `{ "match": { "title": "foo" } }`, + }, + ], + run_as: ['test-run-as-1', 'test-run-as-2'], + }, + kibana: [ + { + base: ['all', 'read'], + spaces: ['*'], + }, + { + base: ['all', 'read'], + spaces: ['test-space-1', 'test-space-2'], + }, + { + feature: { + foo: ['foo-privilege-1', 'foo-privilege-2'], + }, + spaces: ['test-space-3'], + }, + ], + }, + apiResponses: [async () => ({}), async () => {}], + asserts: { + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + applications: [ + { + application, + privileges: ['all', 'read'], + resources: [GLOBAL_RESOURCE], + }, + { + application, + privileges: ['space_all', 'space_read'], + resources: ['space:test-space-1', 'space:test-space-2'], + }, + { + application, + privileges: ['feature_foo.foo-privilege-1', 'feature_foo.foo-privilege-2'], + resources: ['space:test-space-3'], + }, + ], + cluster: ['test-cluster-privilege'], + indices: [ + { + field_security: { + grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], + except: ['test-field-security-except-1', 'test-field-security-except-2'], + }, + names: ['test-index-name-1', 'test-index-name-2'], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], + query: `{ "match": { "title": "foo" } }`, + }, + ], + metadata: { foo: 'test-metadata' }, + run_as: ['test-run-as-1', 'test-run-as-2'], + }, + }, + ], + ], + statusCode: 204, + result: undefined, + }, + }); + + putRoleTest(`updates role which has existing kibana privileges`, { + name: 'foo-role', + payload: { + metadata: { + foo: 'test-metadata', + }, + elasticsearch: { + cluster: ['test-cluster-privilege'], + indices: [ + { + field_security: { + grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], + except: ['test-field-security-except-1', 'test-field-security-except-2'], + }, + names: ['test-index-name-1', 'test-index-name-2'], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], + query: `{ "match": { "title": "foo" } }`, + }, + ], + run_as: ['test-run-as-1', 'test-run-as-2'], + }, + kibana: [ + { + feature: { + foo: ['foo-privilege-1'], + bar: ['bar-privilege-1'], + }, + spaces: ['*'], + }, + { + base: ['all'], + spaces: ['test-space-1', 'test-space-2'], + }, + { + feature: { + bar: ['bar-privilege-2'], + }, + spaces: ['test-space-3'], + }, + ], + }, + apiResponses: [ + async () => ({ + 'foo-role': { + metadata: { + bar: 'old-metadata', + }, + transient_metadata: { + enabled: true, + }, + cluster: ['old-cluster-privilege'], + indices: [ + { + field_security: { + grant: ['old-field-security-grant-1', 'old-field-security-grant-2'], + except: ['old-field-security-except-1', 'old-field-security-except-2'], + }, + names: ['old-index-name'], + privileges: ['old-privilege'], + query: `{ "match": { "old-title": "foo" } }`, + }, + ], + run_as: ['old-run-as'], + applications: [ + { + application, + privileges: ['old-kibana-privilege'], + resources: ['old-resource'], + }, + ], + }, + }), + async () => {}, + ], + asserts: { + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + applications: [ + { + application, + privileges: ['feature_foo.foo-privilege-1', 'feature_bar.bar-privilege-1'], + resources: [GLOBAL_RESOURCE], + }, + { + application, + privileges: ['space_all'], + resources: ['space:test-space-1', 'space:test-space-2'], + }, + { + application, + privileges: ['feature_bar.bar-privilege-2'], + resources: ['space:test-space-3'], + }, + ], + cluster: ['test-cluster-privilege'], + indices: [ + { + field_security: { + grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], + except: ['test-field-security-except-1', 'test-field-security-except-2'], + }, + names: ['test-index-name-1', 'test-index-name-2'], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], + query: `{ "match": { "title": "foo" } }`, + }, + ], + metadata: { foo: 'test-metadata' }, + run_as: ['test-run-as-1', 'test-run-as-2'], + }, + }, + ], + ], + statusCode: 204, + result: undefined, + }, + }); + + putRoleTest(`updates role which has existing other application privileges`, { + name: 'foo-role', + payload: { + metadata: { + foo: 'test-metadata', + }, + elasticsearch: { + cluster: ['test-cluster-privilege'], + indices: [ + { + names: ['test-index-name-1', 'test-index-name-2'], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], + }, + ], + run_as: ['test-run-as-1', 'test-run-as-2'], + }, + kibana: [ + { + base: ['all', 'read'], + spaces: ['*'], + }, + ], + }, + apiResponses: [ + async () => ({ + 'foo-role': { + metadata: { + bar: 'old-metadata', + }, + transient_metadata: { + enabled: true, + }, + cluster: ['old-cluster-privilege'], + indices: [ + { + names: ['old-index-name'], + privileges: ['old-privilege'], + }, + ], + run_as: ['old-run-as'], + applications: [ + { + application, + privileges: ['old-kibana-privilege'], + resources: ['old-resource'], + }, + { + application: 'logstash-foo', + privileges: ['logstash-privilege'], + resources: ['logstash-resource'], + }, + { + application: 'beats-foo', + privileges: ['beats-privilege'], + resources: ['beats-resource'], + }, + ], + }, + }), + async () => {}, + ], + asserts: { + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + applications: [ + { + application, + privileges: ['all', 'read'], + resources: [GLOBAL_RESOURCE], + }, + { + application: 'logstash-foo', + privileges: ['logstash-privilege'], + resources: ['logstash-resource'], + }, + { + application: 'beats-foo', + privileges: ['beats-privilege'], + resources: ['beats-resource'], + }, + ], + cluster: ['test-cluster-privilege'], + indices: [ + { + names: ['test-index-name-1', 'test-index-name-2'], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], + }, + ], + metadata: { foo: 'test-metadata' }, + run_as: ['test-run-as-1', 'test-run-as-2'], + }, + }, + ], + ], + statusCode: 204, + result: undefined, + }, + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.ts new file mode 100644 index 0000000000000..92c940132e660 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.ts @@ -0,0 +1,63 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '../../index'; +import { createLicensedRouteHandler } from '../../licensed_route_handler'; +import { wrapError } from '../../../errors'; +import { + ElasticsearchRole, + getPutPayloadSchema, + transformPutPayloadToElasticsearchRole, +} from './model'; + +export function definePutRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) { + router.put( + { + path: '/api/security/role/{name}', + validate: { + params: schema.object({ name: schema.string({ minLength: 1, maxLength: 1024 }) }), + body: getPutPayloadSchema(() => { + const privileges = authz.privileges.get(); + return { + global: Object.keys(privileges.global), + space: Object.keys(privileges.space), + }; + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const { name } = request.params; + + try { + const rawRoles: Record = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getRole', { + name: request.params.name, + ignore: [404], + }); + + const body = transformPutPayloadToElasticsearchRole( + request.body, + authz.getApplicationName(), + rawRoles[name] ? rawRoles[name].applications : [] + ); + + await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.putRole', { name: request.params.name, body }); + + return response.noContent(); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts new file mode 100644 index 0000000000000..2d3a3154e6499 --- /dev/null +++ b/x-pack/plugins/security/server/routes/index.mock.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 { + elasticsearchServiceMock, + httpServiceMock, + loggingServiceMock, +} from '../../../../../src/core/server/mocks'; +import { authenticationMock } from '../authentication/index.mock'; +import { authorizationMock } from '../authorization/index.mock'; +import { ConfigSchema } from '../config'; + +export const routeDefinitionParamsMock = { + create: () => ({ + router: httpServiceMock.createRouter(), + basePath: httpServiceMock.createBasePath(), + logger: loggingServiceMock.create().get(), + clusterClient: elasticsearchServiceMock.createClusterClient(), + config: { ...ConfigSchema.validate({}), encryptionKey: 'some-enc-key' }, + authc: authenticationMock.create(), + authz: authorizationMock.create(), + getLegacyAPI: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 289f87d70b1de..73e276832f474 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -4,12 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, IRouter, Logger } from '../../../../../src/core/server'; +import { CoreSetup, IClusterClient, IRouter, Logger } from '../../../../../src/core/server'; import { Authentication } from '../authentication'; +import { Authorization } from '../authorization'; import { ConfigType } from '../config'; -import { defineAuthenticationRoutes } from './authentication'; import { LegacyAPI } from '../plugin'; +import { defineAuthenticationRoutes } from './authentication'; +import { defineAuthorizationRoutes } from './authorization'; + /** * Describes parameters used to define HTTP routes. */ @@ -17,11 +20,14 @@ export interface RouteDefinitionParams { router: IRouter; basePath: CoreSetup['http']['basePath']; logger: Logger; + clusterClient: IClusterClient; config: ConfigType; authc: Authentication; - getLegacyAPI: () => LegacyAPI; + authz: Authorization; + getLegacyAPI: () => Pick; } export function defineRoutes(params: RouteDefinitionParams) { defineAuthenticationRoutes(params); + defineAuthorizationRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/licensed_route_handler.ts b/x-pack/plugins/security/server/routes/licensed_route_handler.ts new file mode 100644 index 0000000000000..de5b842c7d292 --- /dev/null +++ b/x-pack/plugins/security/server/routes/licensed_route_handler.ts @@ -0,0 +1,32 @@ +/* + * 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 { RequestHandler } from 'src/core/server'; +import { ObjectType } from '@kbn/config-schema'; +import { LICENSE_STATUS } from '../../../licensing/server/constants'; + +export const createLicensedRouteHandler = < + P extends ObjectType, + Q extends ObjectType, + B extends ObjectType +>( + handler: RequestHandler +) => { + const licensedRouteHandler: RequestHandler = (context, request, responseToolkit) => { + const { license } = context.licensing; + const licenseCheck = license.check('security', 'basic'); + if ( + licenseCheck.check === LICENSE_STATUS.Unavailable || + licenseCheck.check === LICENSE_STATUS.Invalid + ) { + return responseToolkit.forbidden({ body: { message: licenseCheck.message! } }); + } + + return handler(context, request, responseToolkit); + }; + + return licensedRouteHandler; +}; diff --git a/x-pack/plugins/security/server/saved_objects/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts new file mode 100644 index 0000000000000..2bd7440d3ee70 --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/index.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IClusterClient, KibanaRequest, LegacyRequest } from '../../../../../src/core/server'; +import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; +import { LegacyAPI } from '../plugin'; +import { Authorization } from '../authorization'; +import { SecurityAuditLogger } from '../audit'; + +interface SetupSavedObjectsParams { + adminClusterClient: IClusterClient; + auditLogger: SecurityAuditLogger; + authz: Pick; + legacyAPI: Pick; +} + +export function setupSavedObjects({ + adminClusterClient, + auditLogger, + authz, + legacyAPI: { savedObjects }, +}: SetupSavedObjectsParams) { + const getKibanaRequest = (request: KibanaRequest | LegacyRequest) => + request instanceof KibanaRequest ? request : KibanaRequest.from(request); + savedObjects.setScopedSavedObjectsClientFactory(({ request }) => { + const kibanaRequest = getKibanaRequest(request); + if (authz.mode.useRbacForRequest(kibanaRequest)) { + const internalRepository = savedObjects.getSavedObjectsRepository( + adminClusterClient.callAsInternalUser + ); + return new savedObjects.SavedObjectsClient(internalRepository); + } + + const callAsCurrentUserRepository = savedObjects.getSavedObjectsRepository( + adminClusterClient.asScoped(kibanaRequest).callAsCurrentUser + ); + return new savedObjects.SavedObjectsClient(callAsCurrentUserRepository); + }); + + savedObjects.addScopedSavedObjectsClientWrapperFactory( + Number.MAX_SAFE_INTEGER - 1, + 'security', + ({ client, request }) => { + const kibanaRequest = getKibanaRequest(request); + if (authz.mode.useRbacForRequest(kibanaRequest)) { + return new SecureSavedObjectsClientWrapper({ + actions: authz.actions, + auditLogger, + baseClient: client, + checkSavedObjectsPrivilegesAsCurrentUser: authz.checkSavedObjectsPrivilegesWithRequest( + kibanaRequest + ), + errors: savedObjects.SavedObjectsClient.errors, + }); + } + + return client; + } + ); +} diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts new file mode 100644 index 0000000000000..f802c011f207e --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -0,0 +1,822 @@ +/* + * 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 { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; +import { Actions } from '../authorization'; +import { securityAuditLoggerMock } from '../audit/index.mock'; +import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { SavedObjectsClientContract } from 'kibana/server'; + +const createSecureSavedObjectsClientWrapperOptions = () => { + const actions = new Actions('some-version'); + jest + .spyOn(actions.savedObject, 'get') + .mockImplementation((type: string, action: string) => `mock-saved_object:${type}/${action}`); + + const forbiddenError = new Error('Mock ForbiddenError'); + const generalError = new Error('Mock GeneralError'); + + const errors = ({ + decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), + decorateGeneralError: jest.fn().mockReturnValue(generalError), + } as unknown) as jest.Mocked; + + return { + actions, + baseClient: savedObjectsClientMock.create(), + checkSavedObjectsPrivilegesAsCurrentUser: jest.fn(), + errors, + auditLogger: securityAuditLoggerMock.create(), + forbiddenError, + generalError, + }; +}; + +describe('#errors', () => { + test(`assigns errors from constructor to .errors`, () => { + const options = createSecureSavedObjectsClientWrapperOptions(); + const client = new SecureSavedObjectsClientWrapper(options); + + expect(client.errors).toBe(options.errors); + }); +}); + +describe(`spaces disabled`, () => { + describe('#create', () => { + test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { + const type = 'foo'; + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); + + await expect(client.create(type)).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'create')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username, + privileges: { [options.actions.savedObject.get(type, 'create')]: false }, + }); + + const client = new SecureSavedObjectsClientWrapper(options); + + const attributes = { some_attr: 's' }; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.create(type, attributes, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'create')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'create', + [type], + [options.actions.savedObject.get(type, 'create')], + { type, attributes, options: apiCallOptions } + ); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.create when authorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: true, + username, + privileges: { [options.actions.savedObject.get(type, 'create')]: true }, + }); + + const apiCallReturnValue = Symbol(); + options.baseClient.create.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const attributes = { some_attr: 's' }; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.create(type, attributes, apiCallOptions)).resolves.toBe( + apiCallReturnValue + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'create')], + apiCallOptions.namespace + ); + expect(options.baseClient.create).toHaveBeenCalledWith(type, attributes, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'create', + [type], + { type, attributes, options: apiCallOptions } + ); + }); + }); + + describe('#bulkCreate', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect( + client.bulkCreate([{ type, attributes: {} }], apiCallOptions) + ).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'bulk_create')], + apiCallOptions.namespace + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username, + privileges: { + [options.actions.savedObject.get(type1, 'bulk_create')]: false, + [options.actions.savedObject.get(type2, 'bulk_create')]: true, + }, + }); + + const client = new SecureSavedObjectsClientWrapper(options); + + const objects = [{ type: type1, attributes: {} }, { type: type2, attributes: {} }]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkCreate(objects, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + options.actions.savedObject.get(type1, 'bulk_create'), + options.actions.savedObject.get(type2, 'bulk_create'), + ], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'bulk_create', + [type1, type2], + [options.actions.savedObject.get(type1, 'bulk_create')], + { objects, options: apiCallOptions } + ); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.bulkCreate when authorized`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: true, + username, + privileges: { + [options.actions.savedObject.get(type1, 'bulk_create')]: true, + [options.actions.savedObject.get(type2, 'bulk_create')]: true, + }, + }); + + const apiCallReturnValue = Symbol(); + options.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const objects = [ + { type: type1, otherThing: 'sup', attributes: {} }, + { type: type2, otherThing: 'everyone', attributes: {} }, + ]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkCreate(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + options.actions.savedObject.get(type1, 'bulk_create'), + options.actions.savedObject.get(type2, 'bulk_create'), + ], + apiCallOptions.namespace + ); + expect(options.baseClient.bulkCreate).toHaveBeenCalledWith(objects, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'bulk_create', + [type1, type2], + { objects, options: apiCallOptions } + ); + }); + }); + + describe('#delete', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); + + await expect(client.delete(type, 'bar')).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'delete')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type = 'foo'; + const id = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username, + privileges: { + [options.actions.savedObject.get(type, 'delete')]: false, + }, + }); + + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.delete(type, id, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'delete')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'delete', + [type], + [options.actions.savedObject.get(type, 'delete')], + { type, id, options: apiCallOptions } + ); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of internalRepository.delete when authorized`, async () => { + const type = 'foo'; + const id = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: true, + username, + privileges: { [options.actions.savedObject.get(type, 'delete')]: true }, + }); + + const apiCallReturnValue = Symbol(); + options.baseClient.delete.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.delete(type, id, apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'delete')], + apiCallOptions.namespace + ); + expect(options.baseClient.delete).toHaveBeenCalledWith(type, id, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'delete', + [type], + { type, id, options: apiCallOptions } + ); + }); + }); + + describe('#find', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); + + await expect(client.find({ type })).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'find')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username, + privileges: { [options.actions.savedObject.get(type, 'find')]: false }, + }); + + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ type, namespace: 'some-ns' }); + await expect(client.find(apiCallOptions)).rejects.toThrowError(options.forbiddenError); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'find')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'find', + [type], + [options.actions.savedObject.get(type, 'find')], + { options: apiCallOptions } + ); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username, + privileges: { + [options.actions.savedObject.get(type1, 'find')]: false, + [options.actions.savedObject.get(type2, 'find')]: true, + }, + }); + + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + await expect(client.find(apiCallOptions)).rejects.toThrowError(options.forbiddenError); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + options.actions.savedObject.get(type1, 'find'), + options.actions.savedObject.get(type2, 'find'), + ], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'find', + [type1, type2], + [options.actions.savedObject.get(type1, 'find')], + { options: apiCallOptions } + ); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.find when authorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: true, + username, + privileges: { [options.actions.savedObject.get(type, 'find')]: true }, + }); + + const apiCallReturnValue = Symbol(); + options.baseClient.find.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ type, namespace: 'some-ns' }); + await expect(client.find(apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'find')], + apiCallOptions.namespace + ); + expect(options.baseClient.find).toHaveBeenCalledWith(apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'find', + [type], + { options: apiCallOptions } + ); + }); + }); + + describe('#bulkGet', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); + + await expect(client.bulkGet([{ id: 'bar', type }])).rejects.toThrowError( + options.generalError + ); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'bulk_get')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username, + privileges: { + [options.actions.savedObject.get(type1, 'bulk_get')]: false, + [options.actions.savedObject.get(type2, 'bulk_get')]: true, + }, + }); + + const client = new SecureSavedObjectsClientWrapper(options); + + const objects = [{ type: type1, id: `bar-${type1}` }, { type: type2, id: `bar-${type2}` }]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkGet(objects, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + options.actions.savedObject.get(type1, 'bulk_get'), + options.actions.savedObject.get(type2, 'bulk_get'), + ], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'bulk_get', + [type1, type2], + [options.actions.savedObject.get(type1, 'bulk_get')], + { objects, options: apiCallOptions } + ); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.bulkGet when authorized`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: true, + username, + privileges: { + [options.actions.savedObject.get(type1, 'bulk_get')]: true, + [options.actions.savedObject.get(type2, 'bulk_get')]: true, + }, + }); + + const apiCallReturnValue = Symbol(); + options.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const objects = [{ type: type1, id: `id-${type1}` }, { type: type2, id: `id-${type2}` }]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkGet(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + options.actions.savedObject.get(type1, 'bulk_get'), + options.actions.savedObject.get(type2, 'bulk_get'), + ], + apiCallOptions.namespace + ); + expect(options.baseClient.bulkGet).toHaveBeenCalledWith(objects, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'bulk_get', + [type1, type2], + { objects, options: apiCallOptions } + ); + }); + }); + + describe('#get', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); + + await expect(client.get(type, 'bar')).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'get')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type = 'foo'; + const id = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username, + privileges: { + [options.actions.savedObject.get(type, 'get')]: false, + }, + }); + + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.get(type, id, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'get')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'get', + [type], + [options.actions.savedObject.get(type, 'get')], + { type, id, options: apiCallOptions } + ); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.get when authorized`, async () => { + const type = 'foo'; + const id = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: true, + username, + privileges: { [options.actions.savedObject.get(type, 'get')]: true }, + }); + + const apiCallReturnValue = Symbol(); + options.baseClient.get.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.get(type, id, apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'get')], + apiCallOptions.namespace + ); + expect(options.baseClient.get).toHaveBeenCalledWith(type, id, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'get', + [type], + { type, id, options: apiCallOptions } + ); + }); + }); + + describe('#update', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); + + await expect(client.update(type, 'bar', {})).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'update')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type = 'foo'; + const id = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username, + privileges: { + [options.actions.savedObject.get(type, 'update')]: false, + }, + }); + + const client = new SecureSavedObjectsClientWrapper(options); + + const attributes = { some: 'attr' }; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.update(type, id, attributes, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'update')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'update', + [type], + [options.actions.savedObject.get(type, 'update')], + { type, id, attributes, options: apiCallOptions } + ); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.update when authorized`, async () => { + const type = 'foo'; + const id = 'bar'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: true, + username, + privileges: { [options.actions.savedObject.get(type, 'update')]: true }, + }); + + const apiCallReturnValue = Symbol(); + options.baseClient.update.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const attributes = { some: 'attr' }; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.update(type, id, attributes, apiCallOptions)).resolves.toBe( + apiCallReturnValue + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'update')], + apiCallOptions.namespace + ); + expect(options.baseClient.update).toHaveBeenCalledWith(type, id, attributes, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'update', + [type], + { type, id, attributes, options: apiCallOptions } + ); + }); + }); + + describe('#bulkUpdate', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); + + await expect(client.bulkUpdate([{ id: 'bar', type, attributes: {} }])).rejects.toThrowError( + options.generalError + ); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'bulk_update')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username, + privileges: { + [options.actions.savedObject.get(type, 'bulk_update')]: false, + }, + }); + + const client = new SecureSavedObjectsClientWrapper(options); + + const objects = [{ type, id: `bar-${type}`, attributes: {} }]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkUpdate(objects, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'bulk_update')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'bulk_update', + [type], + [options.actions.savedObject.get(type, 'bulk_update')], + { objects, options: apiCallOptions } + ); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.bulkUpdate when authorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: true, + username, + privileges: { + [options.actions.savedObject.get(type, 'bulk_update')]: true, + }, + }); + + const apiCallReturnValue = Symbol(); + options.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const objects = [{ type, id: `id-${type}`, attributes: {} }]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkUpdate(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'bulk_update')], + apiCallOptions.namespace + ); + expect(options.baseClient.bulkUpdate).toHaveBeenCalledWith(objects, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'bulk_update', + [type], + { objects, options: apiCallOptions } + ); + }); + }); +}); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts new file mode 100644 index 0000000000000..03b1d770fa770 --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -0,0 +1,183 @@ +/* + * 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, + SavedObjectsBaseOptions, + SavedObjectsBulkCreateObject, + SavedObjectsBulkGetObject, + SavedObjectsBulkUpdateObject, + SavedObjectsClientContract, + SavedObjectsCreateOptions, + SavedObjectsFindOptions, + SavedObjectsUpdateOptions, +} from '../../../../../src/core/server'; +import { SecurityAuditLogger } from '../audit'; +import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; + +interface SecureSavedObjectsClientWrapperOptions { + actions: Actions; + auditLogger: SecurityAuditLogger; + baseClient: SavedObjectsClientContract; + errors: SavedObjectsClientContract['errors']; + checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; +} + +export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { + private readonly actions: Actions; + private readonly auditLogger: PublicMethodsOf; + private readonly baseClient: SavedObjectsClientContract; + private readonly checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; + public readonly errors: SavedObjectsClientContract['errors']; + constructor({ + actions, + auditLogger, + baseClient, + checkSavedObjectsPrivilegesAsCurrentUser, + errors, + }: SecureSavedObjectsClientWrapperOptions) { + this.errors = errors; + this.actions = actions; + this.auditLogger = auditLogger; + this.baseClient = baseClient; + this.checkSavedObjectsPrivilegesAsCurrentUser = checkSavedObjectsPrivilegesAsCurrentUser; + } + + public async create( + type: string, + attributes: T = {} as T, + options: SavedObjectsCreateOptions = {} + ) { + await this.ensureAuthorized(type, 'create', options.namespace, { type, attributes, options }); + + return await this.baseClient.create(type, attributes, options); + } + + public async bulkCreate( + objects: SavedObjectsBulkCreateObject[], + options: SavedObjectsBaseOptions = {} + ) { + await this.ensureAuthorized( + this.getUniqueObjectTypes(objects), + 'bulk_create', + options.namespace, + { objects, options } + ); + + return await this.baseClient.bulkCreate(objects, options); + } + + public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + await this.ensureAuthorized(type, 'delete', options.namespace, { type, id, options }); + + return await this.baseClient.delete(type, id, options); + } + + public async find(options: SavedObjectsFindOptions) { + await this.ensureAuthorized(options.type, 'find', options.namespace, { options }); + + return this.baseClient.find(options); + } + + public async bulkGet( + objects: SavedObjectsBulkGetObject[] = [], + options: SavedObjectsBaseOptions = {} + ) { + await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_get', options.namespace, { + objects, + options, + }); + + return await this.baseClient.bulkGet(objects, options); + } + + public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + await this.ensureAuthorized(type, 'get', options.namespace, { type, id, options }); + + return await this.baseClient.get(type, id, options); + } + + public async update( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ) { + await this.ensureAuthorized(type, 'update', options.namespace, { + type, + id, + attributes, + options, + }); + + return await this.baseClient.update(type, id, attributes, options); + } + + public async bulkUpdate( + objects: SavedObjectsBulkUpdateObject[] = [], + options: SavedObjectsBaseOptions = {} + ) { + await this.ensureAuthorized( + this.getUniqueObjectTypes(objects), + 'bulk_update', + options && options.namespace, + { objects, options } + ); + + return await this.baseClient.bulkUpdate(objects, options); + } + + private async checkPrivileges(actions: string | string[], namespace?: string) { + try { + return await this.checkSavedObjectsPrivilegesAsCurrentUser(actions, namespace); + } catch (error) { + throw this.errors.decorateGeneralError(error, error.body && error.body.reason); + } + } + + private async ensureAuthorized( + typeOrTypes: string | string[], + action: string, + namespace?: string, + args?: Record + ) { + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + const actionsToTypesMap = new Map( + types.map(type => [this.actions.savedObject.get(type, action), type]) + ); + const actions = Array.from(actionsToTypesMap.keys()); + const { hasAllRequested, username, privileges } = await this.checkPrivileges( + actions, + namespace + ); + + if (hasAllRequested) { + this.auditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); + } else { + const missingPrivileges = this.getMissingPrivileges(privileges); + this.auditLogger.savedObjectsAuthorizationFailure( + username, + action, + types, + missingPrivileges, + args + ); + const msg = `Unable to ${action} ${missingPrivileges + .map(privilege => actionsToTypesMap.get(privilege)) + .sort() + .join(',')}`; + throw this.errors.decorateForbiddenError(new Error(msg)); + } + } + + private getMissingPrivileges(privileges: Record) { + return Object.keys(privileges).filter(privilege => !privileges[privilege]); + } + + private getUniqueObjectTypes(objects: Array<{ type: string }>) { + return [...new Set(objects.map(o => o.type))]; + } +} diff --git a/x-pack/plugins/spaces/kibana.json b/x-pack/plugins/spaces/kibana.json index 15d900bf99e14..ae121e299cc55 100644 --- a/x-pack/plugins/spaces/kibana.json +++ b/x-pack/plugins/spaces/kibana.json @@ -4,6 +4,7 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "spaces"], "requiredPlugins": ["features", "licensing"], + "optionalPlugins": ["security"], "server": true, "ui": false } diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index 1f20fee46ba4c..2b0cfd3687a24 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -27,9 +27,8 @@ import { SpacesAuditLogger } from '../audit_logger'; import { convertSavedObjectToSpace } from '../../routes/lib'; import { initSpacesOnPostAuthRequestInterceptor } from './on_post_auth_interceptor'; import { Feature } from '../../../../features/server'; -import { OptionalPlugin } from '../../../../../legacy/server/lib/optional_plugin'; -import { SecurityPlugin } from '../../../../../legacy/plugins/security'; import { spacesConfig } from '../__fixtures__'; +import { securityMock } from '../../../../security/server/mocks'; describe('onPostAuthInterceptor', () => { let root: ReturnType; @@ -170,7 +169,7 @@ describe('onPostAuthInterceptor', () => { const spacesService = await service.setup({ http: (http as unknown) as CoreSetup['http'], elasticsearch: elasticsearchServiceMock.createSetupContract(), - getSecurity: () => ({} as OptionalPlugin), + authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index e62a3a0efa601..24a994e836e87 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PluginSetupContract as SecuritySetupContract } from '../../../../security/server'; import { SpacesClient } from './spaces_client'; -import { AuthorizationService } from '../../../../../legacy/plugins/security/server/lib/authorization/service'; -import { actionsFactory } from '../../../../../legacy/plugins/security/server/lib/authorization/actions'; import { ConfigType, ConfigSchema } from '../../config'; import { GetSpacePurpose } from '../../../common/model/types'; +import { securityMock } from '../../../../security/server/mocks'; + const createMockAuditLogger = () => { return { spacesAuthorizationFailure: jest.fn(), @@ -21,45 +22,17 @@ const createMockDebugLogger = () => { return jest.fn(); }; -interface MockedAuthorization extends AuthorizationService { - mode: { - useRbacForRequest: jest.Mock; - }; -} const createMockAuthorization = () => { const mockCheckPrivilegesAtSpace = jest.fn(); const mockCheckPrivilegesAtSpaces = jest.fn(); const mockCheckPrivilegesGlobally = jest.fn(); - // mocking base path - const mockConfig = { get: jest.fn().mockReturnValue('/') }; - const mockAuthorization: MockedAuthorization = { - actions: actionsFactory(mockConfig), - application: '', - checkPrivilegesDynamicallyWithRequest: jest.fn().mockImplementation(() => { - throw new Error( - 'checkPrivilegesDynamicallyWithRequest should not be called from this test suite' - ); - }), - checkSavedObjectsPrivilegesWithRequest: jest.fn().mockImplementation(() => { - throw new Error( - 'checkSavedObjectsPrivilegesWithRequest should not be called from this test suite' - ); - }), - privileges: { - get: jest.fn().mockImplementation(() => { - throw new Error('privileges.get() should not be called from this test suite'); - }), - }, - checkPrivilegesWithRequest: jest.fn(() => ({ - atSpaces: mockCheckPrivilegesAtSpaces, - atSpace: mockCheckPrivilegesAtSpace, - globally: mockCheckPrivilegesGlobally, - })), - mode: { - useRbacForRequest: jest.fn(), - }, - }; + const mockAuthorization = securityMock.createSetup().authz; + mockAuthorization.checkPrivilegesWithRequest.mockImplementation(() => ({ + atSpaces: mockCheckPrivilegesAtSpaces, + atSpace: mockCheckPrivilegesAtSpace, + globally: mockCheckPrivilegesGlobally, + })); return { mockCheckPrivilegesAtSpaces, @@ -251,17 +224,17 @@ describe('#getAll', () => { [ { purpose: undefined, - expectedPrivilege: (mockAuthorization: MockedAuthorization) => + expectedPrivilege: (mockAuthorization: SecuritySetupContract['authz']) => mockAuthorization.actions.login, }, { purpose: 'any', - expectedPrivilege: (mockAuthorization: MockedAuthorization) => + expectedPrivilege: (mockAuthorization: SecuritySetupContract['authz']) => mockAuthorization.actions.login, }, { purpose: 'copySavedObjectsIntoSpace', - expectedPrivilege: (mockAuthorization: MockedAuthorization) => + expectedPrivilege: (mockAuthorization: SecuritySetupContract['authz']) => mockAuthorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'), }, ].forEach(scenario => { diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index 052534879e678..f964ae7d7ac32 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -5,22 +5,19 @@ */ import Boom from 'boom'; import { omit } from 'lodash'; -import { Legacy } from 'kibana'; import { KibanaRequest } from 'src/core/server'; -import { AuthorizationService } from '../../../../../legacy/plugins/security/server/lib/authorization/service'; +import { PluginSetupContract as SecurityPluginSetupContract } from '../../../../security/server'; import { isReservedSpace } from '../../../common/is_reserved_space'; import { Space } from '../../../common/model/space'; import { SpacesAuditLogger } from '../audit_logger'; import { ConfigType } from '../../config'; import { GetSpacePurpose } from '../../../common/model/types'; -type SpacesClientRequestFacade = Legacy.Request | KibanaRequest; - const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = ['any', 'copySavedObjectsIntoSpace']; const PURPOSE_PRIVILEGE_MAP: Record< GetSpacePurpose, - (authorization: AuthorizationService) => string + (authorization: SecurityPluginSetupContract['authz']) => string > = { any: authorization => authorization.actions.login, copySavedObjectsIntoSpace: authorization => @@ -31,11 +28,11 @@ export class SpacesClient { constructor( private readonly auditLogger: SpacesAuditLogger, private readonly debugLogger: (message: string) => void, - private readonly authorization: AuthorizationService | null, + private readonly authorization: SecurityPluginSetupContract['authz'] | null, private readonly callWithRequestSavedObjectRepository: any, private readonly config: ConfigType, private readonly internalSavedObjectRepository: any, - private readonly request: SpacesClientRequestFacade + private readonly request: KibanaRequest ) {} public async canEnumerateSpaces(): Promise { @@ -220,10 +217,7 @@ export class SpacesClient { } private useRbac(): boolean { - // TODO: remove "as any" once Security is updated to NP conventions - return ( - this.authorization != null && this.authorization.mode.useRbacForRequest(this.request as any) - ); + return this.authorization != null && this.authorization.mode.useRbacForRequest(this.request); } private async ensureAuthorizedGlobally(action: string, method: string, forbiddenMessage: string) { diff --git a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts index 4fbc4df03d00e..b000c767b53e8 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts @@ -12,9 +12,9 @@ import { SavedObjectsLegacyService } from 'src/core/server'; import { SpacesAuditLogger } from './audit_logger'; import { elasticsearchServiceMock, coreMock } from '../../../../../src/core/server/mocks'; import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; -import { createOptionalPlugin } from '../../../../legacy/server/lib/optional_plugin'; import { LegacyAPI } from '../plugin'; import { spacesConfig } from './__fixtures__'; +import { securityMock } from '../../../security/server/mocks'; const log = { log: jest.fn(), @@ -55,8 +55,7 @@ describe('createSpacesTutorialContextFactory', () => { const spacesService = await service.setup({ http: coreMock.createSetup().http, elasticsearch: elasticsearchServiceMock.createSetupContract(), - getSecurity: () => - createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index 4b071baaa7e2c..aabdc5bcb97e8 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -14,10 +14,9 @@ import { Logger, PluginInitializerContext, } from '../../../../src/core/server'; -import { SecurityPlugin } from '../../../legacy/plugins/security'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { PluginSetupContract as SecurityPluginSetup } from '../../security/server'; import { LicensingPluginSetup } from '../../licensing/server'; -import { OptionalPlugin } from '../../../legacy/server/lib/optional_plugin'; import { XPackMainPlugin } from '../../../legacy/plugins/xpack_main/xpack_main'; import { createDefaultSpace } from './lib/create_default_space'; // @ts-ignore @@ -57,14 +56,12 @@ export interface LegacyAPI { kibanaIndex: string; }; xpackMain: XPackMainPlugin; - // TODO: Spaces has a circular dependency with Security right now. - // Security is not yet available when init runs, so this is wrapped in an optional plugin for the time being. - security: OptionalPlugin; } export interface PluginsSetup { features: FeaturesPluginSetup; licensing: LicensingPluginSetup; + security?: SecurityPluginSetup; } export interface SpacesPluginSetup { @@ -116,7 +113,7 @@ export class Plugin { const spacesService = await service.setup({ http: core.http, elasticsearch: core.elasticsearch, - getSecurity: () => this.getLegacyAPI().security, + authorization: plugins.security ? plugins.security.authz : null, getSpacesAuditLogger: this.getSpacesAuditLogger, config$: this.config$, }); @@ -137,6 +134,10 @@ export class Plugin { features: plugins.features, }); + if (plugins.security) { + plugins.security.registerSpacesService(spacesService); + } + return { spacesService, __legacyCompat: { diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts index 5f366871ba81e..38a973c1203d5 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts @@ -105,7 +105,6 @@ export const createLegacyAPI = ({ }, auditLogger: {} as any, capabilities: {} as any, - security: {} as any, tutorial: {} as any, usage: {} as any, xpackMain: {} as any, diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index 54d9654005f89..f25908147bfe5 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -19,13 +19,13 @@ import { httpServerMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; import { SpacesAuditLogger } from '../../../lib/audit_logger'; import { SpacesClient } from '../../../lib/spaces_client'; import { initCopyToSpacesApi } from './copy_to_space'; import { ObjectType } from '@kbn/config-schema'; import { RouteSchemas } from 'src/core/server/http/router/route'; import { spacesConfig } from '../../../lib/__fixtures__'; +import { securityMock } from '../../../../../security/server/mocks'; describe('copy to space', () => { const spacesSavedObjects = createSpaces(); @@ -45,8 +45,7 @@ describe('copy to space', () => { const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], elasticsearch: elasticsearchServiceMock.createSetupContract(), - getSecurity: () => - createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts index e341bd3e4bcbb..86da3023c515e 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -20,13 +20,13 @@ import { httpServerMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; import { SpacesAuditLogger } from '../../../lib/audit_logger'; import { SpacesClient } from '../../../lib/spaces_client'; import { initDeleteSpacesApi } from './delete'; import { RouteSchemas } from 'src/core/server/http/router/route'; import { ObjectType } from '@kbn/config-schema'; import { spacesConfig } from '../../../lib/__fixtures__'; +import { securityMock } from '../../../../../security/server/mocks'; describe('Spaces Public API', () => { const spacesSavedObjects = createSpaces(); @@ -46,8 +46,7 @@ describe('Spaces Public API', () => { const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], elasticsearch: elasticsearchServiceMock.createSetupContract(), - getSecurity: () => - createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts index 69c4f16d4ca80..f9bd4494791f1 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts @@ -20,10 +20,10 @@ import { httpServerMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; import { SpacesAuditLogger } from '../../../lib/audit_logger'; import { SpacesClient } from '../../../lib/spaces_client'; import { spacesConfig } from '../../../lib/__fixtures__'; +import { securityMock } from '../../../../../security/server/mocks'; describe('GET space', () => { const spacesSavedObjects = createSpaces(); @@ -43,8 +43,7 @@ describe('GET space', () => { const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], elasticsearch: elasticsearchServiceMock.createSetupContract(), - getSecurity: () => - createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index fd31b7d084c0e..02219db88a04c 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -19,11 +19,11 @@ import { httpServerMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; import { SpacesAuditLogger } from '../../../lib/audit_logger'; import { SpacesClient } from '../../../lib/spaces_client'; import { initGetAllSpacesApi } from './get_all'; import { spacesConfig } from '../../../lib/__fixtures__'; +import { securityMock } from '../../../../../security/server/mocks'; describe('GET /spaces/space', () => { const spacesSavedObjects = createSpaces(); @@ -43,8 +43,7 @@ describe('GET /spaces/space', () => { const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], elasticsearch: elasticsearchServiceMock.createSetupContract(), - getSecurity: () => - createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts index f874f96833350..398b2e37191b6 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -19,13 +19,13 @@ import { httpServiceMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; import { SpacesAuditLogger } from '../../../lib/audit_logger'; import { SpacesClient } from '../../../lib/spaces_client'; import { initPostSpacesApi } from './post'; import { RouteSchemas } from 'src/core/server/http/router/route'; import { ObjectType } from '@kbn/config-schema'; import { spacesConfig } from '../../../lib/__fixtures__'; +import { securityMock } from '../../../../../security/server/mocks'; describe('Spaces Public API', () => { const spacesSavedObjects = createSpaces(); @@ -45,8 +45,7 @@ describe('Spaces Public API', () => { const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], elasticsearch: elasticsearchServiceMock.createSetupContract(), - getSecurity: () => - createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts index b06bb41fe8b6b..5c213b7f73f62 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -20,13 +20,13 @@ import { httpServerMock, } from 'src/core/server/mocks'; import { SpacesService } from '../../../spaces_service'; -import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; import { SpacesAuditLogger } from '../../../lib/audit_logger'; import { SpacesClient } from '../../../lib/spaces_client'; import { initPutSpacesApi } from './put'; import { RouteSchemas } from 'src/core/server/http/router/route'; import { ObjectType } from '@kbn/config-schema'; import { spacesConfig } from '../../../lib/__fixtures__'; +import { securityMock } from '../../../../../security/server/mocks'; describe('PUT /api/spaces/space', () => { const spacesSavedObjects = createSpaces(); @@ -46,8 +46,7 @@ describe('PUT /api/spaces/space', () => { const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], elasticsearch: elasticsearchServiceMock.createSetupContract(), - getSecurity: () => - createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts index d0910e00586ed..73791201185e8 100644 --- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts @@ -5,7 +5,7 @@ */ import * as Rx from 'rxjs'; import { SpacesService } from './spaces_service'; -import { coreMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { coreMock, elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; import { SpacesAuditLogger } from '../lib/audit_logger'; import { KibanaRequest, @@ -16,8 +16,8 @@ import { import { DEFAULT_SPACE_ID } from '../../common/constants'; import { getSpaceIdFromPath } from '../../common/lib/spaces_url_parser'; import { LegacyAPI } from '../plugin'; -import { createOptionalPlugin } from '../../../../legacy/server/lib/optional_plugin'; import { spacesConfig } from '../lib/__fixtures__'; +import { securityMock } from '../../../security/server/mocks'; const mockLogger = { trace: jest.fn(), @@ -79,7 +79,7 @@ const createService = async (serverBasePath: string = '') => { http: httpSetup, elasticsearch: elasticsearchServiceMock.createSetupContract(), config$: Rx.of(spacesConfig), - getSecurity: () => createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + authorization: securityMock.createSetup().authz, getSpacesAuditLogger: () => new SpacesAuditLogger({}), }); @@ -183,9 +183,7 @@ describe('SpacesService', () => { describe('#getActiveSpace', () => { it('returns the default space when in the default space', async () => { const spacesServiceSetup = await createService(); - const request = { - url: { path: 'app/kibana' }, - } as KibanaRequest; + const request = httpServerMock.createKibanaRequest({ path: 'app/kibana' }); const activeSpace = await spacesServiceSetup.getActiveSpace(request); expect(activeSpace).toEqual({ @@ -198,9 +196,7 @@ describe('SpacesService', () => { it('returns the space for the current (non-default) space', async () => { const spacesServiceSetup = await createService(); - const request = { - url: { path: '/s/foo/app/kibana' }, - } as KibanaRequest; + const request = httpServerMock.createKibanaRequest({ path: '/s/foo/app/kibana' }); const activeSpace = await spacesServiceSetup.getActiveSpace(request); expect(activeSpace).toEqual({ @@ -212,11 +208,11 @@ describe('SpacesService', () => { it('propagates errors from the repository', async () => { const spacesServiceSetup = await createService(); - const request = { - url: { path: '/s/unknown-space/app/kibana' }, - } as KibanaRequest; + const request = httpServerMock.createKibanaRequest({ path: '/s/unknown-space/app/kibana' }); - expect(spacesServiceSetup.getActiveSpace(request)).rejects.toThrowErrorMatchingInlineSnapshot( + await expect( + spacesServiceSetup.getActiveSpace(request) + ).rejects.toThrowErrorMatchingInlineSnapshot( `"Saved object [space/unknown-space] not found"` ); }); diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts index 83a62f91ade01..b8d0f910a42ea 100644 --- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts @@ -7,9 +7,8 @@ import { map, take } from 'rxjs/operators'; import { Observable, Subscription, combineLatest } from 'rxjs'; import { Legacy } from 'kibana'; -import { Logger, KibanaRequest, CoreSetup } from 'src/core/server'; -import { SecurityPlugin } from '../../../../legacy/plugins/security'; -import { OptionalPlugin } from '../../../../legacy/server/lib/optional_plugin'; +import { Logger, KibanaRequest, CoreSetup } from '../../../../../src/core/server'; +import { PluginSetupContract as SecurityPluginSetup } from '../../../security/server'; import { LegacyAPI } from '../plugin'; import { SpacesClient } from '../lib/spaces_client'; import { ConfigType } from '../config'; @@ -39,7 +38,7 @@ export interface SpacesServiceSetup { interface SpacesServiceDeps { http: CoreSetup['http']; elasticsearch: CoreSetup['elasticsearch']; - getSecurity: () => OptionalPlugin; + authorization: SecurityPluginSetup['authz'] | null; config$: Observable; getSpacesAuditLogger(): any; } @@ -52,7 +51,7 @@ export class SpacesService { public async setup({ http, elasticsearch, - getSecurity, + authorization, config$, getSpacesAuditLogger, }: SpacesServiceDeps): Promise { @@ -69,7 +68,7 @@ export class SpacesService { return spaceId; }; - const getScopedClient = async (request: RequestFacade) => { + const getScopedClient = async (request: KibanaRequest) => { return combineLatest(elasticsearch.adminClient$, config$) .pipe( map(([clusterClient, config]) => { @@ -85,10 +84,6 @@ export class SpacesService { ['space'] ); - const security = getSecurity(); - - const authorization = security.isEnabled ? security.authorization : null; - return new SpacesClient( getSpacesAuditLogger(), (message: string) => { @@ -124,7 +119,9 @@ export class SpacesService { scopedClient: getScopedClient, getActiveSpace: async (request: RequestFacade) => { const spaceId = getSpaceId(request); - const spacesClient = await getScopedClient(request); + const spacesClient = await getScopedClient( + request instanceof KibanaRequest ? request : KibanaRequest.from(request) + ); return spacesClient.get(spaceId); }, }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 28db2197638ff..07076a7fa461d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -556,7 +556,6 @@ "common.ui.vislib.colormaps.greysText": "グレー", "common.ui.vislib.colormaps.redsText": "赤", "common.ui.vislib.colormaps.yellowToRedText": "黄色から赤", - "common.ui.visualize.dataLoaderError": "ビジュアライゼーションエラー", "common.ui.visualize.queryGeohashBounds.unableToGetBoundErrorTitle": "バウンドを取得できませんでした", "common.ui.welcomeErrorMessage": "Kibana が正常に読み込まれませんでした。詳細はサーバーアウトプットを確認してください。", "common.ui.welcomeMessage": "Kibana を読み込み中", @@ -656,6 +655,20 @@ "core.euiSuperUpdateButton.refreshButtonLabel": "更新", "core.euiSuperUpdateButton.updateButtonLabel": "更新", "core.euiSuperUpdateButton.updatingButtonLabel": "更新中", + "kibana-react.tableListView.listing.deleteButtonMessage": "{itemCount} 件の {entityName} を削除", + "kibana-react.tableListView.listing.deleteConfirmModalDescription": "削除された {entityNamePlural} は復元できません。", + "kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.cancelButtonLabel": "キャンセル", + "kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabel": "削除", + "kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabelDeleting": "削除中", + "kibana-react.tableListView.listing.listingLimitExceeded.advancedSettingsLinkText": "高度な設定", + "kibana-react.tableListView.listing.listingLimitExceededDescription": "{totalItems} 件の {entityNamePlural} がありますが、{listingLimitText} の設定により {listingLimitValue} 件までしか下の表に表示できません。この設定は {advancedSettingsLink} で変更できます。", + "kibana-react.tableListView.listing.listingLimitExceededTitle": "リスティング制限超過", + "kibana-react.tableListView.listing.noAvailableItemsMessage": "利用可能な {entityNamePlural} がありません。", + "kibana-react.tableListView.listing.noMatchedItemsMessage": "検索条件に一致する {entityNamePlural} がありません。", + "kibana-react.tableListView.listing.table.actionTitle": "アクション", + "kibana-react.tableListView.listing.table.editActionDescription": "編集", + "kibana-react.tableListView.listing.table.editActionName": "編集", + "kibana-react.tableListView.listing.unableToDeleteDangerMessage": "{entityName} を削除できません", "kibana-react.exitFullScreenButton.exitFullScreenModeButtonAreaLabel": "全画面モードを終了", "kibana-react.exitFullScreenButton.exitFullScreenModeButtonLabel": "全画面を終了", "kibana-react.exitFullScreenButton.fullScreenModeDescription": "ESC キーで全画面モードを終了します。", @@ -2371,20 +2384,6 @@ "kbn.server.tutorials.zookeeperMetrics.nameTitle": "Zookeeper メトリック", "kbn.server.tutorials.zookeeperMetrics.shortDescription": "Zookeeper サーバーから内部メトリックを取得します。", "kbn.settings.advancedSettings.voiceAnnouncement.searchResultScreenReaderMessage": "{query} を検索しました。{sectionLenght, plural, one {# セクション} other {# セクション}}に{optionLenght, plural, one {# オプション} other { # オプション}}があります。", - "kbn.table_list_view.listing.deleteButtonMessage": "{itemCount} 件の {entityName} を削除", - "kbn.table_list_view.listing.deleteConfirmModalDescription": "削除された {entityNamePlural} は復元できません。", - "kbn.table_list_view.listing.deleteSelectedItemsConfirmModal.cancelButtonLabel": "キャンセル", - "kbn.table_list_view.listing.deleteSelectedItemsConfirmModal.confirmButtonLabel": "削除", - "kbn.table_list_view.listing.deleteSelectedItemsConfirmModal.confirmButtonLabelDeleting": "削除中", - "kbn.table_list_view.listing.listingLimitExceeded.advancedSettingsLinkText": "高度な設定", - "kbn.table_list_view.listing.listingLimitExceededDescription": "{totalItems} 件の {entityNamePlural} がありますが、{listingLimitText} の設定により {listingLimitValue} 件までしか下の表に表示できません。この設定は {advancedSettingsLink} で変更できます。", - "kbn.table_list_view.listing.listingLimitExceededTitle": "リスティング制限超過", - "kbn.table_list_view.listing.noAvailableItemsMessage": "利用可能な {entityNamePlural} がありません。", - "kbn.table_list_view.listing.noMatchedItemsMessage": "検索条件に一致する {entityNamePlural} がありません。", - "kbn.table_list_view.listing.table.actionTitle": "アクション", - "kbn.table_list_view.listing.table.editActionDescription": "編集", - "kbn.table_list_view.listing.table.editActionName": "編集", - "kbn.table_list_view.listing.unableToDeleteDangerMessage": "{entityName} を削除できません", "kbn.topNavMenu.openInspectorButtonLabel": "検査", "kbn.topNavMenu.refreshButtonLabel": "更新", "kbn.topNavMenu.saveVisualizationButtonLabel": "保存", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6cf125aee8f96..61421acb7dc4a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -557,7 +557,6 @@ "common.ui.vislib.colormaps.greysText": "灰色", "common.ui.vislib.colormaps.redsText": "红色", "common.ui.vislib.colormaps.yellowToRedText": "黄到红", - "common.ui.visualize.dataLoaderError": "可视化错误", "common.ui.visualize.queryGeohashBounds.unableToGetBoundErrorTitle": "无法获取边界", "common.ui.welcomeErrorMessage": "Kibana 未正确加载。检查服务器输出以了解详情。", "common.ui.welcomeMessage": "正在加载 Kibana", @@ -660,6 +659,20 @@ "kibana-react.exitFullScreenButton.exitFullScreenModeButtonAreaLabel": "退出全屏模式", "kibana-react.exitFullScreenButton.exitFullScreenModeButtonLabel": "退出全屏", "kibana-react.exitFullScreenButton.fullScreenModeDescription": "在全屏模式下,按 ESC 键可退出。", + "kibana-react.tableListView.listing.deleteButtonMessage": "删除 {itemCount} 个{entityName}", + "kibana-react.tableListView.listing.deleteConfirmModalDescription": "您无法恢复删除的{entityNamePlural}。", + "kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.cancelButtonLabel": "取消", + "kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabel": "删除", + "kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabelDeleting": "正在删除", + "kibana-react.tableListView.listing.listingLimitExceeded.advancedSettingsLinkText": "高级设置", + "kibana-react.tableListView.listing.listingLimitExceededDescription": "您有 {totalItems} 个{entityNamePlural},但您的“{listingLimitText}”设置阻止下表显示 {listingLimitValue} 个以上。您可以在“{advancedSettingsLink}”下更改此设置。", + "kibana-react.tableListView.listing.listingLimitExceededTitle": "已超过列表限制", + "kibana-react.tableListView.listing.noAvailableItemsMessage": "没有可用的{entityNamePlural}", + "kibana-react.tableListView.listing.noMatchedItemsMessage": "没有任何{entityNamePlural}匹配您的搜索。", + "kibana-react.tableListView.listing.table.actionTitle": "操作", + "kibana-react.tableListView.listing.table.editActionDescription": "编辑", + "kibana-react.tableListView.listing.table.editActionName": "编辑", + "kibana-react.tableListView.listing.unableToDeleteDangerMessage": "无法删除{entityName}", "inspector.closeButton": "关闭检查器", "inspector.reqTimestampDescription": "记录请求启动的时间", "inspector.reqTimestampKey": "请求时间戳", @@ -2372,20 +2385,6 @@ "kbn.server.tutorials.zookeeperMetrics.nameTitle": "Zookeeper 指标", "kbn.server.tutorials.zookeeperMetrics.shortDescription": "从 Zookeeper 服务器提取内部指标。", "kbn.settings.advancedSettings.voiceAnnouncement.searchResultScreenReaderMessage": "您已搜索 {query}。{sectionLenght, plural, one {# 个部分} other {# 个部分}}中有 {optionLenght, plural, one {# 个选项} other {# 个选项}}", - "kbn.table_list_view.listing.deleteButtonMessage": "删除 {itemCount} 个{entityName}", - "kbn.table_list_view.listing.deleteConfirmModalDescription": "您无法恢复删除的{entityNamePlural}。", - "kbn.table_list_view.listing.deleteSelectedItemsConfirmModal.cancelButtonLabel": "取消", - "kbn.table_list_view.listing.deleteSelectedItemsConfirmModal.confirmButtonLabel": "删除", - "kbn.table_list_view.listing.deleteSelectedItemsConfirmModal.confirmButtonLabelDeleting": "正在删除", - "kbn.table_list_view.listing.listingLimitExceeded.advancedSettingsLinkText": "高级设置", - "kbn.table_list_view.listing.listingLimitExceededDescription": "您有 {totalItems} 个{entityNamePlural},但您的“{listingLimitText}”设置阻止下表显示 {listingLimitValue} 个以上。您可以在“{advancedSettingsLink}”下更改此设置。", - "kbn.table_list_view.listing.listingLimitExceededTitle": "已超过列表限制", - "kbn.table_list_view.listing.noAvailableItemsMessage": "没有可用的{entityNamePlural}", - "kbn.table_list_view.listing.noMatchedItemsMessage": "没有任何{entityNamePlural}匹配您的搜索。", - "kbn.table_list_view.listing.table.actionTitle": "操作", - "kbn.table_list_view.listing.table.editActionDescription": "编辑", - "kbn.table_list_view.listing.table.editActionName": "编辑", - "kbn.table_list_view.listing.unableToDeleteDangerMessage": "无法删除{entityName}", "kbn.topNavMenu.openInspectorButtonLabel": "检查", "kbn.topNavMenu.refreshButtonLabel": "刷新", "kbn.topNavMenu.saveVisualizationButtonLabel": "保存", diff --git a/x-pack/test/api_integration/apis/management/index_management/settings.js b/x-pack/test/api_integration/apis/management/index_management/settings.js index bed71c0a62166..dc41f530085b1 100644 --- a/x-pack/test/api_integration/apis/management/index_management/settings.js +++ b/x-pack/test/api_integration/apis/management/index_management/settings.js @@ -37,7 +37,6 @@ export default function ({ getService }) { 'max_terms_count', 'lifecycle', 'routing_partition_size', - 'force_memory_term_dictionary', 'max_docvalue_fields_search', 'merge', 'max_refresh_listeners', diff --git a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts index cf22394a08616..efce016a16209 100644 --- a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts +++ b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts @@ -11,10 +11,10 @@ export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('Builtin ES Privileges', () => { - describe('GET /api/security/v1/esPrivileges/builtin', () => { + describe('GET /internal/security/esPrivileges/builtin', () => { it('should return a list of available builtin privileges', async () => { await supertest - .get('/api/security/v1/esPrivileges/builtin') + .get('/internal/security/esPrivileges/builtin') .set('kbn-xsrf', 'xxx') .send() .expect(200) diff --git a/x-pack/test/functional/services/machine_learning/navigation.ts b/x-pack/test/functional/services/machine_learning/navigation.ts index a55eae9122485..06ab99b3dcb9f 100644 --- a/x-pack/test/functional/services/machine_learning/navigation.ts +++ b/x-pack/test/functional/services/machine_learning/navigation.ts @@ -32,12 +32,10 @@ export function MachineLearningNavigationProvider({ }, async navigateToArea(linkSubject: string, pageSubject: string) { - await retry.tryForTime(2 * 60 * 1000, async () => { - if ((await testSubjects.exists(`${linkSubject} selected`)) === false) { - await testSubjects.click(linkSubject); - await testSubjects.existOrFail(`${linkSubject} selected`, { timeout: 30 * 1000 }); - await testSubjects.existOrFail(pageSubject, { timeout: 30 * 1000 }); - } + await testSubjects.click(linkSubject); + await retry.tryForTime(60 * 1000, async () => { + await testSubjects.existOrFail(`${linkSubject} & ~selected`); + await testSubjects.existOrFail(pageSubject); }); }, @@ -51,11 +49,11 @@ export function MachineLearningNavigationProvider({ }, async navigateToOverview() { - await this.navigateToArea('mlMainTab overview', 'mlPageOverview'); + await this.navigateToArea('~mlMainTab & ~overview', 'mlPageOverview'); }, async navigateToAnomalyDetection() { - await this.navigateToArea('mlMainTab anomalyDetection', 'mlPageJobManagement'); + await this.navigateToArea('~mlMainTab & ~anomalyDetection', 'mlPageJobManagement'); await this.assertTabsExist('mlSubTab', [ 'jobManagement', 'anomalyExplorer', @@ -65,33 +63,33 @@ export function MachineLearningNavigationProvider({ }, async navigateToDataFrameAnalytics() { - await this.navigateToArea('mlMainTab dataFrameAnalytics', 'mlPageDataFrameAnalytics'); + await this.navigateToArea('~mlMainTab & ~dataFrameAnalytics', 'mlPageDataFrameAnalytics'); await this.assertTabsExist('mlSubTab', []); }, async navigateToDataVisualizer() { - await this.navigateToArea('mlMainTab dataVisualizer', 'mlPageDataVisualizerSelector'); + await this.navigateToArea('~mlMainTab & ~dataVisualizer', 'mlPageDataVisualizerSelector'); await this.assertTabsExist('mlSubTab', []); }, async navigateToJobManagement() { await this.navigateToAnomalyDetection(); - await this.navigateToArea('mlSubTab jobManagement', 'mlPageJobManagement'); + await this.navigateToArea('~mlSubTab & ~jobManagement', 'mlPageJobManagement'); }, async navigateToAnomalyExplorer() { await this.navigateToAnomalyDetection(); - await this.navigateToArea('mlSubTab anomalyExplorer', 'mlPageAnomalyExplorer'); + await this.navigateToArea('~mlSubTab & ~anomalyExplorer', 'mlPageAnomalyExplorer'); }, async navigateToSingleMetricViewer() { await this.navigateToAnomalyDetection(); - await this.navigateToArea('mlSubTab singleMetricViewer', 'mlPageSingleMetricViewer'); + await this.navigateToArea('~mlSubTab & ~singleMetricViewer', 'mlPageSingleMetricViewer'); }, async navigateToSettings() { await this.navigateToAnomalyDetection(); - await this.navigateToArea('mlSubTab settings', 'mlPageSettings'); + await this.navigateToArea('~mlSubTab & ~settings', 'mlPageSettings'); }, }; } diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts index 3ea00890aedeb..6b15d1bff4209 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts @@ -66,9 +66,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { expect(uiCapabilities.value!.catalogue).to.eql(expected); break; } - // if we don't have access at the space itself, we're - // redirected to the space selector and the ui capabilities - // are lagely irrelevant because they won't be consumed + // if we don't have access at the space itself, security interceptor responds with 404. case 'no_kibana_privileges at everything_space': case 'no_kibana_privileges at nothing_space': case 'legacy_all at everything_space': @@ -78,9 +76,7 @@ export default function catalogueTests({ getService }: FtrProviderContext) { case 'nothing_space_all at everything_space': case 'nothing_space_read at everything_space': expect(uiCapabilities.success).to.be(false); - expect(uiCapabilities.failureReason).to.be( - GetUICapabilitiesFailureReason.RedirectedToSpaceSelector - ); + expect(uiCapabilities.failureReason).to.be(GetUICapabilitiesFailureReason.NotFound); break; default: throw new UnreachableError(scenario); diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/foo.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/foo.ts index ef3162fe9ddd9..ad4c3582d468f 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/foo.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/foo.ts @@ -70,9 +70,7 @@ export default function fooTests({ getService }: FtrProviderContext) { show: false, }); break; - // if we don't have access at the space itself, we're - // redirected to the space selector and the ui capabilities - // are largely irrelevant because they won't be consumed + // if we don't have access at the space itself, security interceptor responds with 404. case 'no_kibana_privileges at everything_space': case 'no_kibana_privileges at nothing_space': case 'legacy_all at everything_space': @@ -82,9 +80,7 @@ export default function fooTests({ getService }: FtrProviderContext) { case 'nothing_space_all at everything_space': case 'nothing_space_read at everything_space': expect(uiCapabilities.success).to.be(false); - expect(uiCapabilities.failureReason).to.be( - GetUICapabilitiesFailureReason.RedirectedToSpaceSelector - ); + expect(uiCapabilities.failureReason).to.be(GetUICapabilitiesFailureReason.NotFound); break; default: throw new UnreachableError(scenario); diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts index 1b9c1daf90282..e9d0cf28e96ec 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts @@ -62,6 +62,7 @@ export default function navLinksTests({ getService }: FtrProviderContext) { expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql(navLinksBuilder.only('management')); break; + // if we don't have access at the space itself, security interceptor responds with 404. case 'no_kibana_privileges at everything_space': case 'no_kibana_privileges at nothing_space': case 'legacy_all at everything_space': @@ -71,9 +72,7 @@ export default function navLinksTests({ getService }: FtrProviderContext) { case 'nothing_space_all at everything_space': case 'nothing_space_read at everything_space': expect(uiCapabilities.success).to.be(false); - expect(uiCapabilities.failureReason).to.be( - GetUICapabilitiesFailureReason.RedirectedToSpaceSelector - ); + expect(uiCapabilities.failureReason).to.be(GetUICapabilitiesFailureReason.NotFound); break; default: throw new UnreachableError(scenario); diff --git a/yarn.lock b/yarn.lock index 93e3dbfaa45ad..71f032cf720dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1064,10 +1064,10 @@ debug "^3.1.0" lodash.once "^4.1.1" -"@elastic/charts@^13.5.12": - version "13.5.12" - resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-13.5.12.tgz#95bd92389ec5fb411debfa5979091b6da2e4b123" - integrity sha512-MMNuebZ5jmzXkUJZr/mSvmtWNIR0gWGBtbqpZBfq3T9WRQPvnEHeE/N1WmXw2BSvwN86fy1i0gr52izh/nfzjQ== +"@elastic/charts@^14.0.0": + version "14.0.0" + resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-14.0.0.tgz#410c87e9ae53df5848aae09a210fa7d08510b376" + integrity sha512-cskrD5Yq6yTTqGOKV2/7dw/eRON1GbWkIgSuWXPIBa/TQMUwiWqxFkxSMUJSbu9xXq07KMblDgXLf73yMc0AyQ== dependencies: "@types/d3-shape" "^1.3.1" classnames "^2.2.6" @@ -14679,11 +14679,6 @@ hoek@6.x.x: resolved "https://registry.yarnpkg.com/hoek/-/hoek-6.0.3.tgz#7884360426d927865a0a1251fc9c59313af5b798" integrity sha512-TU6RyZ/XaQCTWRLrdqZZtZqwxUVr6PDMfi6MlWNURZ7A6czanQqX4pFE1mdOUQR9FdPCsZ0UzL8jI/izZ+eBSQ== -hoist-non-react-statics@^2.3.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz#d2ca2dfc19c5a91c5a6615ce8e564ef0347e2a40" - integrity sha512-6Bl6XsDT1ntE0lHbIhr4Kp2PGcleGZ66qu5Jqk8lc0Xc/IeG6gVLmwUGs/K0Us+L8VWoKgj0uWdPMataOsm31w== - hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0, hoist-non-react-statics@^2.5.5: version "2.5.5" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" @@ -23061,18 +23056,6 @@ react-reverse-portal@^1.0.4: resolved "https://registry.yarnpkg.com/react-reverse-portal/-/react-reverse-portal-1.0.4.tgz#d127d2c9147549b25c4959aba1802eca4b144cd4" integrity sha512-WESex/wSjxHwdG7M0uwPNkdQXaLauXNHi4INQiRybmFIXVzAqgf/Ak2OzJ4MLf4UuCD/IzEwJOkML2SxnnontA== -react-router-dom@4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.2.2.tgz#c8a81df3adc58bba8a76782e946cbd4eae649b8d" - integrity sha512-cHMFC1ZoLDfEaMFoKTjN7fry/oczMgRt5BKfMAkTu5zEuJvUiPp1J8d0eXSVTnBh6pxlbdqDhozunOOLtmKfPA== - dependencies: - history "^4.7.2" - invariant "^2.2.2" - loose-envify "^1.3.1" - prop-types "^15.5.4" - react-router "^4.2.0" - warning "^3.0.0" - react-router-dom@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6" @@ -23103,19 +23086,6 @@ react-router@^3.2.0: prop-types "^15.5.6" warning "^3.0.0" -react-router@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.2.0.tgz#61f7b3e3770daeb24062dae3eedef1b054155986" - integrity sha512-DY6pjwRhdARE4TDw7XjxjZsbx9lKmIcyZoZ+SDO7SBJ1KUeWNxT22Kara2AC7u6/c2SYEHlEDLnzBCcNhLE8Vg== - dependencies: - history "^4.7.2" - hoist-non-react-statics "^2.3.0" - invariant "^2.2.2" - loose-envify "^1.3.1" - path-to-regexp "^1.7.0" - prop-types "^15.5.4" - warning "^3.0.0" - react-router@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.3.1.tgz#aada4aef14c809cb2e686b05cee4742234506c4e"