From 01b5cf0a88a0ddd905a8802326b521916c85c452 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Mon, 26 Mar 2018 10:40:36 -0400 Subject: [PATCH 001/183] partial implementation for OLS Phase 1 --- .../client/lib/client_provider.js | 41 ++++++++++++++ src/server/saved_objects/client/lib/index.js | 2 + .../client/lib/interceptor_registry.js | 37 +++++++++++++ .../client/saved_objects_client.js | 53 +++++++++++++++++-- .../saved_objects/saved_objects_mixin.js | 9 ++-- 5 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 src/server/saved_objects/client/lib/client_provider.js create mode 100644 src/server/saved_objects/client/lib/interceptor_registry.js diff --git a/src/server/saved_objects/client/lib/client_provider.js b/src/server/saved_objects/client/lib/client_provider.js new file mode 100644 index 00000000000000..2f26731b91b9c3 --- /dev/null +++ b/src/server/saved_objects/client/lib/client_provider.js @@ -0,0 +1,41 @@ + +/** + * The default Saved Object Client provider. + * A custom implementation may be substituted by calling `SavedObjectClientProvider.setClientProvider` + * + * @param {*} server + * @param {*} request + */ +const DEFAULT_PROVIDER = function savedObjectsClientProvider(server, request) { + + const cluster = server.plugins.elasticsearch.getCluster('admin'); + + const callCluster = (...args) => cluster.callWithRequest(request, ...args); + + return server.savedObjectsClientFactory({ callCluster, request }); +}; + +let activeProvider = DEFAULT_PROVIDER; + +/** + * Provider for the Saved Object Client. + */ +class ClientProvider { + constructor() { + this._activeProvider = DEFAULT_PROVIDER; + } + + setClientProvider(provider) { + if (activeProvider !== DEFAULT_PROVIDER) { + throw new Error(`A Saved Objects Client Provider has already been registered. Registering multiple providers is not supported.`); + } + + activeProvider = provider; + } + + getClientProvider() { + return activeProvider; + } +} + +export const SavedObjectClientProvider = new ClientProvider(); diff --git a/src/server/saved_objects/client/lib/index.js b/src/server/saved_objects/client/lib/index.js index 3e90b4820f9c75..724046af2713a1 100644 --- a/src/server/saved_objects/client/lib/index.js +++ b/src/server/saved_objects/client/lib/index.js @@ -2,6 +2,8 @@ export { getSearchDsl } from './search_dsl'; export { trimIdPrefix } from './trim_id_prefix'; export { includedFields } from './included_fields'; export { decorateEsError } from './decorate_es_error'; +export { SavedObjectClientProvider } from './client_provider'; +export { SavedObjectClientInterceptorRegistry } from './interceptor_registry'; import * as errors from './errors'; export { errors }; diff --git a/src/server/saved_objects/client/lib/interceptor_registry.js b/src/server/saved_objects/client/lib/interceptor_registry.js new file mode 100644 index 00000000000000..ac6205eb9d1adf --- /dev/null +++ b/src/server/saved_objects/client/lib/interceptor_registry.js @@ -0,0 +1,37 @@ +/** + * Registry for Saved Objects Client Request Interceptors. + * + * Interceptors will be invoked by the Saved Object Client prior to calling the ElasticSearch cluster, + * which allows interceptors to modify or interrupt the process. + */ +class SavedObjectClientRequestInterceptorRegistry { + constructor() { + this._interceptorFactories = []; + } + + registerInterceptorFactory(interceptorFactory) { + if (typeof interceptorFactory !== 'function') { + throw new Error(`Invalid interceptor factory - must be a function`); + } + + this._interceptorFactories.push(interceptorFactory); + } + + createInterceptorsForRequest(request) { + const interceptors = this._interceptorFactories.map(factory => factory(request)); + + const anyInvalid = interceptors.some( + interceptor => typeof interceptor.method !== 'string' || typeof interceptor.intercept !== 'function' + ); + + if (anyInvalid) { + throw new Error( + `One or more interceptors are invalid: each interceptor must include a 'method' property, and an 'intercept' function` + ); + } + + return interceptors; + } +} + +export const SavedObjectClientInterceptorRegistry = new SavedObjectClientRequestInterceptorRegistry(); diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index 79dc55540e039d..b6049dcebd0a53 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -17,12 +17,14 @@ export class SavedObjectsClient { mappings, callCluster, onBeforeWrite = () => {}, + interceptors = [] } = options; this._index = index; this._mappings = mappings; this._type = getRootType(this._mappings); this._onBeforeWrite = onBeforeWrite; + this._interceptors = interceptors; this._unwrappedCallCluster = callCluster; } @@ -114,6 +116,8 @@ export class SavedObjectsClient { const time = this._getCurrentTime(); try { + await this._invokeRequestInterceptors(method, type, attributes, options); + const response = await this._writeToCluster(method, { id: this._generateEsId(type, id), type: this._type, @@ -156,9 +160,12 @@ export class SavedObjectsClient { overwrite = false } = options; const time = this._getCurrentTime(); - const objectToBulkRequest = (object) => { + + const objectToBulkRequest = async (object) => { const method = object.id && !overwrite ? 'create' : 'index'; + await this._invokeRequestInterceptors(method, object.type, object.attributes, options); + return [ { [method]: { @@ -174,13 +181,18 @@ export class SavedObjectsClient { ]; }; + const bulkRequestBody = await objects.reduce(async (acc, object) => { + const collection = await acc; + + const objectRequestBody = await objectToBulkRequest(object); + + return [...collection, ...objectRequestBody]; + }, Promise.resolve([])); + const { items } = await this._writeToCluster('bulk', { index: this._index, refresh: 'wait_for', - body: objects.reduce((acc, object) => ([ - ...acc, - ...objectToBulkRequest(object) - ]), []), + body: bulkRequestBody }); return items.map((response, i) => { @@ -224,6 +236,8 @@ export class SavedObjectsClient { * @returns {promise} */ async delete(type, id) { + await this._invokeRequestInterceptors('delete', type, id); + const response = await this._writeToCluster('delete', { id: this._generateEsId(type, id), type: this._type, @@ -282,6 +296,10 @@ export class SavedObjectsClient { throw new TypeError('options.searchFields must be an array'); } + const method = 'search'; + + await this._invokeRequestInterceptors(method, type); + const esOptions = { index: this._index, size: perPage, @@ -347,6 +365,10 @@ export class SavedObjectsClient { return { saved_objects: [] }; } + const method = 'mget'; + + await this._invokeRequestInterceptors(method, null); + const response = await this._callCluster('mget', { index: this._index, body: { @@ -389,6 +411,10 @@ export class SavedObjectsClient { * @returns {promise} - { id, type, version, attributes } */ async get(type, id) { + const method = 'get'; + + await this._invokeRequestInterceptors(method, type, id); + const response = await this._callCluster('get', { id: this._generateEsId(type, id), type: this._type, @@ -424,6 +450,8 @@ export class SavedObjectsClient { * @returns {promise} */ async update(type, id, attributes, options = {}) { + await this._invokeRequestInterceptors('update', type, attributes, options); + const time = this._getCurrentTime(); const response = await this._writeToCluster('update', { id: this._generateEsId(type, id), @@ -478,4 +506,19 @@ export class SavedObjectsClient { _getCurrentTime() { return new Date().toISOString(); } + + _collectRequestInterceptors(method) { + return this._interceptors.filter(interceptor => interceptor.method === method || interceptor.method === 'all'); + } + + async _invokeRequestInterceptors(method, type, ...args) { + const interceptors = this._collectRequestInterceptors(method); + + for (const interceptor of interceptors) { + if (typeof interceptor.intercept !== 'function') { + throw new Error(`Request interceptor missing 'intercept' function`); + } + await interceptor.intercept(this, method, type, ...args); + } + } } diff --git a/src/server/saved_objects/saved_objects_mixin.js b/src/server/saved_objects/saved_objects_mixin.js index 9a8ceb97704ee1..826f87c511f01f 100644 --- a/src/server/saved_objects/saved_objects_mixin.js +++ b/src/server/saved_objects/saved_objects_mixin.js @@ -1,4 +1,5 @@ import { SavedObjectsClient } from './client'; +import { SavedObjectClientInterceptorRegistry, SavedObjectClientProvider } from './client/lib'; import { createBulkGetRoute, @@ -62,12 +63,13 @@ export function savedObjectsMixin(kbnServer, server) { } } - server.decorate('server', 'savedObjectsClientFactory', ({ callCluster }) => { + server.decorate('server', 'savedObjectsClientFactory', ({ callCluster, request }) => { return new SavedObjectsClient({ index: server.config().get('kibana.index'), mappings: server.getKibanaIndexMappingsDsl(), callCluster, onBeforeWrite, + interceptors: request ? SavedObjectClientInterceptorRegistry.createInterceptorsForRequest(request) : [] }); }); @@ -79,9 +81,8 @@ export function savedObjectsMixin(kbnServer, server) { return savedObjectsClientCache.get(request); } - const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); - const callCluster = (...args) => callWithRequest(request, ...args); - const savedObjectsClient = server.savedObjectsClientFactory({ callCluster }); + const clientProvider = SavedObjectClientProvider.getClientProvider(); + const savedObjectsClient = clientProvider(server, request); savedObjectsClientCache.set(request, savedObjectsClient); return savedObjectsClient; From 7cee640d3b2191ccac507a11e1e30770832745e6 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 17 Apr 2018 08:19:47 -0400 Subject: [PATCH 002/183] Allow Saved Objects Client to be wrapped --- .../client/lib/client_provider.js | 56 +++++++++++------- src/server/saved_objects/client/lib/index.js | 3 +- .../client/lib/interceptor_registry.js | 37 ------------ .../client/lib/prioritized_collection.js | 40 +++++++++++++ .../client/lib/prioritized_collection.test.js | 57 +++++++++++++++++++ .../client/saved_objects_client.js | 34 ----------- .../saved_objects/saved_objects_mixin.js | 17 +++--- 7 files changed, 142 insertions(+), 102 deletions(-) delete mode 100644 src/server/saved_objects/client/lib/interceptor_registry.js create mode 100644 src/server/saved_objects/client/lib/prioritized_collection.js create mode 100644 src/server/saved_objects/client/lib/prioritized_collection.test.js diff --git a/src/server/saved_objects/client/lib/client_provider.js b/src/server/saved_objects/client/lib/client_provider.js index 2f26731b91b9c3..e561274b963a12 100644 --- a/src/server/saved_objects/client/lib/client_provider.js +++ b/src/server/saved_objects/client/lib/client_provider.js @@ -1,41 +1,55 @@ +import { SavedObjectsClient } from '../saved_objects_client'; +import { PrioritizedCollection } from './prioritized_collection'; /** - * The default Saved Object Client provider. - * A custom implementation may be substituted by calling `SavedObjectClientProvider.setClientProvider` + * The base Saved Objects Client. * * @param {*} server * @param {*} request */ -const DEFAULT_PROVIDER = function savedObjectsClientProvider(server, request) { - - const cluster = server.plugins.elasticsearch.getCluster('admin'); - - const callCluster = (...args) => cluster.callWithRequest(request, ...args); - - return server.savedObjectsClientFactory({ callCluster, request }); -}; - -let activeProvider = DEFAULT_PROVIDER; +function createBaseSavedObjectsClient(options) { + + const { + server, + mappings, + callCluster, + onBeforeWrite, + } = options; + + return new SavedObjectsClient({ + index: server.config().get('kibana.index'), + mappings, + callCluster, + onBeforeWrite + }); +} /** * Provider for the Saved Object Client. */ class ClientProvider { constructor() { - this._activeProvider = DEFAULT_PROVIDER; + this._optionBuilders = new PrioritizedCollection('optionBuilders'); + this._wrappers = new PrioritizedCollection('savedObjectClientWrappers'); } - setClientProvider(provider) { - if (activeProvider !== DEFAULT_PROVIDER) { - throw new Error(`A Saved Objects Client Provider has already been registered. Registering multiple providers is not supported.`); - } + addClientOptionBuilder(builder, priority) { + this._optionBuilders.add(builder, priority); + } - activeProvider = provider; + addClientWrapper(wrapper, priority) { + this._wrappers.add(wrapper, priority); } - getClientProvider() { - return activeProvider; + createWrappedSavedObjectsClient(options) { + const orderedBuilders = this._optionBuilders.toArray(); + const clientOptions = orderedBuilders.reduce((acc, builder) => builder(acc), options); + + const baseClient = createBaseSavedObjectsClient(clientOptions); + + const orderedWrappers = this._wrappers.toArray(); + return orderedWrappers.reduce((client, wrapper) => wrapper(client, clientOptions), baseClient); } } -export const SavedObjectClientProvider = new ClientProvider(); +export const SavedObjectsClientProvider = new ClientProvider(); diff --git a/src/server/saved_objects/client/lib/index.js b/src/server/saved_objects/client/lib/index.js index 724046af2713a1..ac10009b591180 100644 --- a/src/server/saved_objects/client/lib/index.js +++ b/src/server/saved_objects/client/lib/index.js @@ -2,8 +2,7 @@ export { getSearchDsl } from './search_dsl'; export { trimIdPrefix } from './trim_id_prefix'; export { includedFields } from './included_fields'; export { decorateEsError } from './decorate_es_error'; -export { SavedObjectClientProvider } from './client_provider'; -export { SavedObjectClientInterceptorRegistry } from './interceptor_registry'; +export { SavedObjectsClientProvider } from './client_provider'; import * as errors from './errors'; export { errors }; diff --git a/src/server/saved_objects/client/lib/interceptor_registry.js b/src/server/saved_objects/client/lib/interceptor_registry.js deleted file mode 100644 index ac6205eb9d1adf..00000000000000 --- a/src/server/saved_objects/client/lib/interceptor_registry.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Registry for Saved Objects Client Request Interceptors. - * - * Interceptors will be invoked by the Saved Object Client prior to calling the ElasticSearch cluster, - * which allows interceptors to modify or interrupt the process. - */ -class SavedObjectClientRequestInterceptorRegistry { - constructor() { - this._interceptorFactories = []; - } - - registerInterceptorFactory(interceptorFactory) { - if (typeof interceptorFactory !== 'function') { - throw new Error(`Invalid interceptor factory - must be a function`); - } - - this._interceptorFactories.push(interceptorFactory); - } - - createInterceptorsForRequest(request) { - const interceptors = this._interceptorFactories.map(factory => factory(request)); - - const anyInvalid = interceptors.some( - interceptor => typeof interceptor.method !== 'string' || typeof interceptor.intercept !== 'function' - ); - - if (anyInvalid) { - throw new Error( - `One or more interceptors are invalid: each interceptor must include a 'method' property, and an 'intercept' function` - ); - } - - return interceptors; - } -} - -export const SavedObjectClientInterceptorRegistry = new SavedObjectClientRequestInterceptorRegistry(); diff --git a/src/server/saved_objects/client/lib/prioritized_collection.js b/src/server/saved_objects/client/lib/prioritized_collection.js new file mode 100644 index 00000000000000..e958ac678a120e --- /dev/null +++ b/src/server/saved_objects/client/lib/prioritized_collection.js @@ -0,0 +1,40 @@ +/** + * A simple collection of entities that can be prioritized. + */ +export class PrioritizedCollection { + constructor(name) { + this._name = name; + this._entities = {}; + } + + /** + * Add an entity to this collection. + * + * @param {*} entity the entity to store + * @param {number} priority optionally specify a priority. Omit to use the next available priority. + */ + add(entity, priority = this._getNextPriority()) { + if (this._entities.hasOwnProperty(priority)) { + throw new Error(`${this._name} already has an entry with priority ${priority}. Please choose a different priority.`); + } + if (typeof priority !== 'number') { + throw new Error(`Priority for ${this._name} must be a number.`); + } + + this._entities[priority] = entity; + } + + /** + * Returns an array of all entities, in priority order. + */ + toArray() { + return Object + .keys(this._entities) + .sort((priority1, priority2) => priority1 - priority2) + .map(key => this._entities[key]); + } + + _getNextPriority() { + return Math.max(0, ...Object.keys(this._entities)) + 1; + } +} diff --git a/src/server/saved_objects/client/lib/prioritized_collection.test.js b/src/server/saved_objects/client/lib/prioritized_collection.test.js new file mode 100644 index 00000000000000..56583403f34c2b --- /dev/null +++ b/src/server/saved_objects/client/lib/prioritized_collection.test.js @@ -0,0 +1,57 @@ +import { PrioritizedCollection } from './prioritized_collection'; + +describe('PrioritizedCollection', () => { + it('should add a single entry with a default priority', () => { + const collection = new PrioritizedCollection('unit test'); + const entity = {}; + collection.add(entity); + + const result = collection.toArray(); + expect(result).to.equal([entity]); + }); + + it('should prioritize entities in the order in which they are added', () => { + const collection = new PrioritizedCollection('unit test'); + const entity1 = {}; + const entity2 = {}; + + collection.add(entity1); + collection.add(entity2); + + const result = collection.toArray(); + expect(result).to.equal([entity1, entity2]); + }); + + it('should honor the provided priority', () => { + const collection = new PrioritizedCollection('unit test'); + const entity1 = {}; + const entity2 = {}; + + collection.add(entity1, 10); + collection.add(entity2, 1); + + const result = collection.toArray(); + expect(result).to.equal([entity2, entity1]); + }); + + it('should throw an error when a duplicate priority is specified', () => { + const collection = new PrioritizedCollection('unit test'); + const entity1 = {}; + const entity2 = {}; + + collection.add(entity1, 10); + + expect(() => { + collection.add(entity2, 10); + }).to.throwError(`unit test already has an entry with priority 10. Please choose a different priority.`); + }); + + it('should throw an error when an invalid priority is specified', () => { + const collection = new PrioritizedCollection('unit test'); + const entity1 = {}; + + expect(() => { + collection.add(entity1, 'not a number'); + }).to.throwError(`Priority for unit test must be a number.`); + }); +}); diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index b6049dcebd0a53..092dd0e4b45b26 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -17,14 +17,12 @@ export class SavedObjectsClient { mappings, callCluster, onBeforeWrite = () => {}, - interceptors = [] } = options; this._index = index; this._mappings = mappings; this._type = getRootType(this._mappings); this._onBeforeWrite = onBeforeWrite; - this._interceptors = interceptors; this._unwrappedCallCluster = callCluster; } @@ -116,8 +114,6 @@ export class SavedObjectsClient { const time = this._getCurrentTime(); try { - await this._invokeRequestInterceptors(method, type, attributes, options); - const response = await this._writeToCluster(method, { id: this._generateEsId(type, id), type: this._type, @@ -164,8 +160,6 @@ export class SavedObjectsClient { const objectToBulkRequest = async (object) => { const method = object.id && !overwrite ? 'create' : 'index'; - await this._invokeRequestInterceptors(method, object.type, object.attributes, options); - return [ { [method]: { @@ -236,7 +230,6 @@ export class SavedObjectsClient { * @returns {promise} */ async delete(type, id) { - await this._invokeRequestInterceptors('delete', type, id); const response = await this._writeToCluster('delete', { id: this._generateEsId(type, id), @@ -296,10 +289,6 @@ export class SavedObjectsClient { throw new TypeError('options.searchFields must be an array'); } - const method = 'search'; - - await this._invokeRequestInterceptors(method, type); - const esOptions = { index: this._index, size: perPage, @@ -365,10 +354,6 @@ export class SavedObjectsClient { return { saved_objects: [] }; } - const method = 'mget'; - - await this._invokeRequestInterceptors(method, null); - const response = await this._callCluster('mget', { index: this._index, body: { @@ -411,9 +396,6 @@ export class SavedObjectsClient { * @returns {promise} - { id, type, version, attributes } */ async get(type, id) { - const method = 'get'; - - await this._invokeRequestInterceptors(method, type, id); const response = await this._callCluster('get', { id: this._generateEsId(type, id), @@ -450,7 +432,6 @@ export class SavedObjectsClient { * @returns {promise} */ async update(type, id, attributes, options = {}) { - await this._invokeRequestInterceptors('update', type, attributes, options); const time = this._getCurrentTime(); const response = await this._writeToCluster('update', { @@ -506,19 +487,4 @@ export class SavedObjectsClient { _getCurrentTime() { return new Date().toISOString(); } - - _collectRequestInterceptors(method) { - return this._interceptors.filter(interceptor => interceptor.method === method || interceptor.method === 'all'); - } - - async _invokeRequestInterceptors(method, type, ...args) { - const interceptors = this._collectRequestInterceptors(method); - - for (const interceptor of interceptors) { - if (typeof interceptor.intercept !== 'function') { - throw new Error(`Request interceptor missing 'intercept' function`); - } - await interceptor.intercept(this, method, type, ...args); - } - } } diff --git a/src/server/saved_objects/saved_objects_mixin.js b/src/server/saved_objects/saved_objects_mixin.js index 826f87c511f01f..4169dedc77aaba 100644 --- a/src/server/saved_objects/saved_objects_mixin.js +++ b/src/server/saved_objects/saved_objects_mixin.js @@ -1,5 +1,4 @@ -import { SavedObjectsClient } from './client'; -import { SavedObjectClientInterceptorRegistry, SavedObjectClientProvider } from './client/lib'; +import { SavedObjectsClientProvider } from './client/lib'; import { createBulkGetRoute, @@ -64,12 +63,12 @@ export function savedObjectsMixin(kbnServer, server) { } server.decorate('server', 'savedObjectsClientFactory', ({ callCluster, request }) => { - return new SavedObjectsClient({ - index: server.config().get('kibana.index'), + return SavedObjectsClientProvider.createWrappedSavedObjectsClient({ + server, + request, mappings: server.getKibanaIndexMappingsDsl(), callCluster, - onBeforeWrite, - interceptors: request ? SavedObjectClientInterceptorRegistry.createInterceptorsForRequest(request) : [] + onBeforeWrite }); }); @@ -81,8 +80,10 @@ export function savedObjectsMixin(kbnServer, server) { return savedObjectsClientCache.get(request); } - const clientProvider = SavedObjectClientProvider.getClientProvider(); - const savedObjectsClient = clientProvider(server, request); + const { callWithRequest } = server.plugins.elasticsearch.getCluster('admin'); + const callCluster = (...args) => callWithRequest(request, ...args); + + const savedObjectsClient = server.savedObjectsClientFactory({ callCluster, request }); savedObjectsClientCache.set(request, savedObjectsClient); return savedObjectsClient; From 0afd1c1c0093c4a5521c9ecc7efd1f6caeb09619 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 17 Apr 2018 17:02:02 -0400 Subject: [PATCH 003/183] Add placeholder "kibana.namespace" configuration property --- src/core_plugins/kibana/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index 189009f037ea9d..afabf0c0e8b8c0 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -30,7 +30,8 @@ export default function (kibana) { return Joi.object({ enabled: Joi.boolean().default(true), defaultAppId: Joi.string().default('home'), - index: Joi.string().default('.kibana') + index: Joi.string().default('.kibana'), + namespace: Joi.string().default('kibana') }).default(); }, From 30e86d12684e648fa0f94721e65a34b44b639a66 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 18 Apr 2018 14:12:01 -0400 Subject: [PATCH 004/183] revert changes to saved objects client --- .../client/saved_objects_client.js | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js index 092dd0e4b45b26..79dc55540e039d 100644 --- a/src/server/saved_objects/client/saved_objects_client.js +++ b/src/server/saved_objects/client/saved_objects_client.js @@ -156,8 +156,7 @@ export class SavedObjectsClient { overwrite = false } = options; const time = this._getCurrentTime(); - - const objectToBulkRequest = async (object) => { + const objectToBulkRequest = (object) => { const method = object.id && !overwrite ? 'create' : 'index'; return [ @@ -175,18 +174,13 @@ export class SavedObjectsClient { ]; }; - const bulkRequestBody = await objects.reduce(async (acc, object) => { - const collection = await acc; - - const objectRequestBody = await objectToBulkRequest(object); - - return [...collection, ...objectRequestBody]; - }, Promise.resolve([])); - const { items } = await this._writeToCluster('bulk', { index: this._index, refresh: 'wait_for', - body: bulkRequestBody + body: objects.reduce((acc, object) => ([ + ...acc, + ...objectToBulkRequest(object) + ]), []), }); return items.map((response, i) => { @@ -230,7 +224,6 @@ export class SavedObjectsClient { * @returns {promise} */ async delete(type, id) { - const response = await this._writeToCluster('delete', { id: this._generateEsId(type, id), type: this._type, @@ -396,7 +389,6 @@ export class SavedObjectsClient { * @returns {promise} - { id, type, version, attributes } */ async get(type, id) { - const response = await this._callCluster('get', { id: this._generateEsId(type, id), type: this._type, @@ -432,7 +424,6 @@ export class SavedObjectsClient { * @returns {promise} */ async update(type, id, attributes, options = {}) { - const time = this._getCurrentTime(); const response = await this._writeToCluster('update', { id: this._generateEsId(type, id), From 936180b82c6d9fdd3625f583e564b0f6bc2ef682 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 19 Apr 2018 13:15:38 -0400 Subject: [PATCH 005/183] Remove circular dependency --- .../client/lib/client_provider.js | 28 ++----------------- .../saved_objects/saved_objects_mixin.js | 20 +++++++++++-- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/server/saved_objects/client/lib/client_provider.js b/src/server/saved_objects/client/lib/client_provider.js index e561274b963a12..a0e510a75fb756 100644 --- a/src/server/saved_objects/client/lib/client_provider.js +++ b/src/server/saved_objects/client/lib/client_provider.js @@ -1,29 +1,5 @@ -import { SavedObjectsClient } from '../saved_objects_client'; import { PrioritizedCollection } from './prioritized_collection'; -/** - * The base Saved Objects Client. - * - * @param {*} server - * @param {*} request - */ -function createBaseSavedObjectsClient(options) { - - const { - server, - mappings, - callCluster, - onBeforeWrite, - } = options; - - return new SavedObjectsClient({ - index: server.config().get('kibana.index'), - mappings, - callCluster, - onBeforeWrite - }); -} - /** * Provider for the Saved Object Client. */ @@ -41,11 +17,11 @@ class ClientProvider { this._wrappers.add(wrapper, priority); } - createWrappedSavedObjectsClient(options) { + createSavedObjectsClient(baseClientFactory, options) { const orderedBuilders = this._optionBuilders.toArray(); const clientOptions = orderedBuilders.reduce((acc, builder) => builder(acc), options); - const baseClient = createBaseSavedObjectsClient(clientOptions); + const baseClient = baseClientFactory(clientOptions); const orderedWrappers = this._wrappers.toArray(); return orderedWrappers.reduce((client, wrapper) => wrapper(client, clientOptions), baseClient); diff --git a/src/server/saved_objects/saved_objects_mixin.js b/src/server/saved_objects/saved_objects_mixin.js index 4169dedc77aaba..d5d7b2c4c31cf5 100644 --- a/src/server/saved_objects/saved_objects_mixin.js +++ b/src/server/saved_objects/saved_objects_mixin.js @@ -1,5 +1,5 @@ import { SavedObjectsClientProvider } from './client/lib'; - +import { SavedObjectsClient } from './client'; import { createBulkGetRoute, createCreateRoute, @@ -63,7 +63,23 @@ export function savedObjectsMixin(kbnServer, server) { } server.decorate('server', 'savedObjectsClientFactory', ({ callCluster, request }) => { - return SavedObjectsClientProvider.createWrappedSavedObjectsClient({ + const createBaseClient = (options) => { + const { + server, + mappings, + callCluster, + onBeforeWrite, + } = options; + + return new SavedObjectsClient({ + index: server.config().get('kibana.index'), + mappings, + callCluster, + onBeforeWrite + }); + }; + + return SavedObjectsClientProvider.createSavedObjectsClient(createBaseClient, { server, request, mappings: server.getKibanaIndexMappingsDsl(), From 4d4f9462f896f2394531d071fcb293c9164edf01 Mon Sep 17 00:00:00 2001 From: kobelb Date: Tue, 24 Apr 2018 10:12:25 -0400 Subject: [PATCH 006/183] Removing namespace setting, we're using xpack.security.rbac.application --- src/core_plugins/kibana/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index afabf0c0e8b8c0..a8e759424a8429 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -31,7 +31,6 @@ export default function (kibana) { enabled: Joi.boolean().default(true), defaultAppId: Joi.string().default('home'), index: Joi.string().default('.kibana'), - namespace: Joi.string().default('kibana') }).default(); }, From df569df451728867264bf248046367b1711373f4 Mon Sep 17 00:00:00 2001 From: kobelb Date: Tue, 24 Apr 2018 12:21:25 -0400 Subject: [PATCH 007/183] Adding config.getDefault --- src/server/config/config.js | 15 +++++++++++++++ src/server/config/config.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/server/config/config.js b/src/server/config/config.js index 61276a645f7deb..e415ef04e39bb1 100644 --- a/src/server/config/config.js +++ b/src/server/config/config.js @@ -123,11 +123,24 @@ export class Config { return clone(value); } + getDefault(key) { + const schemaDescription = Joi.describe(this.getSchema()); + const parts = key.split('.'); + const path = `children.${parts.join('.children.')}`; + const description = _.get(schemaDescription, path); + if (!description) { + throw new Error('Unknown config key: ' + key); + } + + return _.get(description, 'flags.default'); + } + has(key) { function has(key, schema, path) { path = path || []; // Catch the partial paths if (path.join('.') === key) return true; + // Only go deep on inner objects with children if (_.size(schema._inner.children)) { for (let i = 0; i < schema._inner.children.length; i++) { @@ -174,4 +187,6 @@ export class Config { return this[schema]; } + + } diff --git a/src/server/config/config.test.js b/src/server/config/config.test.js index ac45557e22cb55..14b7ffd2226d9c 100644 --- a/src/server/config/config.test.js +++ b/src/server/config/config.test.js @@ -213,6 +213,31 @@ describe('lib/config/config', function () { }); + describe('#getDefault(key)', function () { + let config; + + beforeEach(function () { + config = new Config(schema); + config.set(data); + }); + + it('should return undefined if there is no default', function () { + const hostDefault = config.getDefault('test.client.host'); + expect(hostDefault).toBeUndefined(); + }); + + it('should return default if specified', function () { + const typeDefault = config.getDefault('test.client.type'); + expect(typeDefault).toBe('datastore'); + }); + + it('should throw exception for unknown key', function () { + expect(() => { + config.getDefault('foo.bar'); + }).toThrow(); + }); + }); + describe('#extendSchema(key, schema)', function () { let config; beforeEach(function () { From 9979fb9c2654c3aef289ed5c9e895a7cf213c9fd Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 25 Apr 2018 09:25:36 -0400 Subject: [PATCH 008/183] Expose SavedObjectsClientProvider on the server for easy plugin consumption --- src/server/saved_objects/saved_objects_mixin.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/saved_objects/saved_objects_mixin.js b/src/server/saved_objects/saved_objects_mixin.js index d5d7b2c4c31cf5..2cf83946de63b1 100644 --- a/src/server/saved_objects/saved_objects_mixin.js +++ b/src/server/saved_objects/saved_objects_mixin.js @@ -62,6 +62,8 @@ export function savedObjectsMixin(kbnServer, server) { } } + server.decorate('server', 'getSavedObjectsClientProvider', () => SavedObjectsClientProvider); + server.decorate('server', 'savedObjectsClientFactory', ({ callCluster, request }) => { const createBaseClient = (options) => { const { From 646a80a1f1bdaa0b005300d76113f90799d7263c Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 25 Apr 2018 10:46:40 -0400 Subject: [PATCH 009/183] migrate x-pack changes into kibana --- x-pack/plugins/security/index.js | 37 ++++- .../public/services/application_privilege.js | 19 +++ .../public/services/shield_privileges.js | 3 +- .../public/views/management/edit_role.html | 24 +++ .../public/views/management/edit_role.js | 28 +++- .../server/lib/__tests__/validate_config.js | 19 ++- .../lib/authorization/create_default_roles.js | 47 ++++++ .../server/lib/check_user_permission.js | 14 ++ .../lib/mirror_status_and_initialize.js | 63 ++++++++ .../lib/mirror_status_and_initialize.test.js | 138 ++++++++++++++++++ .../privileges/privilege_action_registry.js | 32 ++++ .../server/lib/privileges/privileges.js | 56 +++++++ .../security/server/lib/role_schema.js | 3 +- .../lib/saved_object_client_interceptor.js | 42 ++++++ .../saved_objects_client_wrapper.js | 17 +++ .../secure_options_builder.js | 19 +++ .../secure_saved_objects_client.js | 96 ++++++++++++ .../security/server/lib/validate_config.js | 11 +- .../server/routes/api/v1/privileges.js | 28 ++++ .../v1/roles/contains_other_applications.js | 15 ++ .../roles/contains_other_applications.test.js | 66 +++++++++ .../api/v1/{roles.js => roles/index.js} | 24 ++- x-pack/server/lib/esjs_shield_plugin.js | 31 ++++ 23 files changed, 814 insertions(+), 18 deletions(-) create mode 100644 x-pack/plugins/security/public/services/application_privilege.js create mode 100644 x-pack/plugins/security/server/lib/authorization/create_default_roles.js create mode 100644 x-pack/plugins/security/server/lib/check_user_permission.js create mode 100644 x-pack/plugins/security/server/lib/mirror_status_and_initialize.js create mode 100644 x-pack/plugins/security/server/lib/mirror_status_and_initialize.test.js create mode 100644 x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js create mode 100644 x-pack/plugins/security/server/lib/privileges/privileges.js create mode 100644 x-pack/plugins/security/server/lib/saved_object_client_interceptor.js create mode 100644 x-pack/plugins/security/server/lib/saved_objects_client/saved_objects_client_wrapper.js create mode 100644 x-pack/plugins/security/server/lib/saved_objects_client/secure_options_builder.js create mode 100644 x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js create mode 100644 x-pack/plugins/security/server/routes/api/v1/privileges.js create mode 100644 x-pack/plugins/security/server/routes/api/v1/roles/contains_other_applications.js create mode 100644 x-pack/plugins/security/server/routes/api/v1/roles/contains_other_applications.test.js rename x-pack/plugins/security/server/routes/api/v1/{roles.js => roles/index.js} (69%) diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 5e4602d4480844..2810f7ae3cb7fb 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -16,7 +16,12 @@ import { validateConfig } from './server/lib/validate_config'; import { authenticateFactory } from './server/lib/auth_redirect'; import { checkLicense } from './server/lib/check_license'; import { initAuthenticator } from './server/lib/authentication/authenticator'; -import { mirrorPluginStatus } from '../../server/lib/mirror_plugin_status'; +import { mirrorStatusAndInitialize } from './server/lib/mirror_status_and_initialize'; +import { secureSavedObjectsClientWrapper } from './server/lib/saved_objects_client/saved_objects_client_wrapper'; +import { secureSavedObjectsClientOptionsBuilder } from './server/lib/saved_objects_client/secure_options_builder'; +import { registerPrivilegesWithCluster } from './server/lib/privileges/privilege_action_registry'; +import { createDefaultRoles } from './server/lib/authorization/create_default_roles'; +import { initPrivilegesApi } from './server/routes/api/v1/privileges'; export const security = (kibana) => new kibana.Plugin({ id: 'security', @@ -37,6 +42,11 @@ export const security = (kibana) => new kibana.Plugin({ hostname: Joi.string().hostname(), port: Joi.number().integer().min(0).max(65535) }).default(), + rbac: Joi.object({ + enabled: Joi.boolean().default(false), + createDefaultRoles: Joi.boolean().default(true), + application: Joi.string().default('kibana'), + }).default(), }).default(); }, @@ -64,21 +74,29 @@ export const security = (kibana) => new kibana.Plugin({ return { secureCookies: config.get('xpack.security.secureCookies'), - sessionTimeout: config.get('xpack.security.sessionTimeout') + sessionTimeout: config.get('xpack.security.sessionTimeout'), + rbacEnabled: config.get('xpack.security.rbac.enabled') }; } }, async init(server) { - const thisPlugin = this; + const config = server.config(); const xpackMainPlugin = server.plugins.xpack_main; - mirrorPluginStatus(xpackMainPlugin, thisPlugin); + + mirrorStatusAndInitialize(xpackMainPlugin.status, this.status, async () => { + if (!config.get('xpack.security.rbac.enabled')) { + return; + } + + await registerPrivilegesWithCluster(server); + await createDefaultRoles(server); + }); // Register a function that is called whenever the xpack info changes, // to re-compute the license check results for this plugin - xpackMainPlugin.info.feature(thisPlugin.id).registerLicenseCheckResultsGenerator(checkLicense); + xpackMainPlugin.info.feature(this.id).registerLicenseCheckResultsGenerator(checkLicense); - const config = server.config(); validateConfig(config, message => server.log(['security', 'warning'], message)); // Create a Hapi auth scheme that should be applied to each request. @@ -88,6 +106,12 @@ export const security = (kibana) => new kibana.Plugin({ // automatically assigned to all routes that don't contain an auth config. server.auth.strategy('session', 'login', 'required'); + if (config.get('xpack.security.rbac.enabled')) { + const savedObjectsClientProvider = server.getSavedObjectsClientProvider(); + savedObjectsClientProvider.addClientOptionBuilder((options) => secureSavedObjectsClientOptionsBuilder(server, options)); + savedObjectsClientProvider.addClientWrapper(secureSavedObjectsClientWrapper); + } + getUserProvider(server); await initAuthenticator(server); @@ -95,6 +119,7 @@ export const security = (kibana) => new kibana.Plugin({ initUsersApi(server); initRolesApi(server); initIndicesApi(server); + initPrivilegesApi(server); initLoginView(server, xpackMainPlugin); initLogoutView(server); diff --git a/x-pack/plugins/security/public/services/application_privilege.js b/x-pack/plugins/security/public/services/application_privilege.js new file mode 100644 index 00000000000000..2b21eb7e63e4bd --- /dev/null +++ b/x-pack/plugins/security/public/services/application_privilege.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 'angular-resource'; +import { uiModules } from 'ui/modules'; + +const module = uiModules.get('security', ['ngResource']); +module.service('ApplicationPrivilege', ($resource, chrome) => { + const baseUrl = chrome.addBasePath('/api/security/v1/privileges'); + const ShieldPrivilege = $resource(baseUrl); + + return ShieldPrivilege; +}); diff --git a/x-pack/plugins/security/public/services/shield_privileges.js b/x-pack/plugins/security/public/services/shield_privileges.js index aabd0a95b26cab..a00b326ab82da3 100644 --- a/x-pack/plugins/security/public/services/shield_privileges.js +++ b/x-pack/plugins/security/public/services/shield_privileges.js @@ -35,5 +35,6 @@ module.constant('shieldPrivileges', { 'create_index', 'view_index_metadata', 'read_cross_cluster', - ] + ], + applications: [] }); diff --git a/x-pack/plugins/security/public/views/management/edit_role.html b/x-pack/plugins/security/public/views/management/edit_role.html index f547788606cc32..768f50a2107586 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.html +++ b/x-pack/plugins/security/public/views/management/edit_role.html @@ -96,6 +96,30 @@

+ +
+ + +
+ Changes to this section are not supported: this role contains application privileges that do not belong to this instance of Kibana. +
+ +
+ +
+
+

} + description={ +

+ Allow requests to be submitted on the behalf of other users. {this.learnMore(documentationLinks.esRunAsPrivileges)} +

+ } + > + + ({ id: username, label: username }))} + selectedOptions={this.props.role.run_as.map(u => ({ label: u }))} + onChange={this.onRunAsUserChange} + isDisabled={!this.props.editable} + /> + + + + + +

Index privileges

+ + +

Control access to the data in your cluster. {this.learnMore(documentationLinks.esIndicesPrivileges)}

+
+ + + + + + {this.props.editable && ( + Add index privilege + )} + + ); + } + + learnMore = (href) => ( + + Learn more + + ); + + addIndexPrivilege = () => { + const { role } = this.props; + + const newIndices = [...role.indices, { + names: [], + privileges: [], + field_security: { + grant: ['*'] + } + }]; + + this.props.onChange({ + ...this.props.role, + indices: newIndices + }); + }; + + onClusterPrivilegesChange = (cluster) => { + const role = { + ...this.props.role, + cluster + }; + + this.props.onChange(role); + } + + onRunAsUserChange = (users) => { + const role = { + ...this.props.role, + run_as: users.map(u => u.label) + }; + + this.props.onChange(role); + } +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.less b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.less new file mode 100644 index 00000000000000..776ef72a4627e5 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.less @@ -0,0 +1,3 @@ +.editRole__learnMore { + margin-left: 5px; +} \ No newline at end of file diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.test.js new file mode 100644 index 00000000000000..7a2af5723e543d --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/elasticsearch_privileges.test.js @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { IndexPrivileges } from './index_privileges'; +import { ClusterPrivileges } from './cluster_privileges'; +import { ElasticsearchPrivileges } from './elasticsearch_privileges'; +import { RoleValidator } from '../../lib/validate_role'; + +test('it renders without crashing', () => { + const props = { + role: { + cluster: [], + indices: [], + run_as: [] + }, + editable: true, + httpClient: jest.fn(), + onChange: jest.fn(), + runAsUsers: [], + indexPatterns: [], + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); + +test('it renders ClusterPrivileges', () => { + const props = { + role: { + cluster: [], + indices: [], + run_as: [] + }, + editable: true, + httpClient: jest.fn(), + onChange: jest.fn(), + runAsUsers: [], + indexPatterns: [], + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + }; + const wrapper = mount(); + expect(wrapper.find(ClusterPrivileges)).toHaveLength(1); +}); + +test('it renders IndexPrivileges', () => { + const props = { + role: { + cluster: [], + indices: [], + run_as: [] + }, + editable: true, + httpClient: jest.fn(), + onChange: jest.fn(), + runAsUsers: [], + indexPatterns: [], + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + }; + const wrapper = mount(); + expect(wrapper.find(IndexPrivileges)).toHaveLength(1); +}); \ No newline at end of file diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.js new file mode 100644 index 00000000000000..d142af394b1555 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.js @@ -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 { ElasticsearchPrivileges } from './elasticsearch_privileges'; +export { KibanaPrivileges } from './kibana_privileges'; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.js new file mode 100644 index 00000000000000..ee62b80c5e282f --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.js @@ -0,0 +1,260 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiComboBox, + EuiTextArea, + EuiFormRow, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiSpacer, + EuiHorizontalRule, + EuiButtonIcon, +} from '@elastic/eui'; +import { getIndexPrivileges } from '../../../../../services/role_privileges'; + +const fromOption = (option) => option.label; +const toOption = (value) => ({ label: value }); + +export class IndexPrivilegeForm extends Component { + static propTypes = { + indexPrivilege: PropTypes.object.isRequired, + indexPatterns: PropTypes.array.isRequired, + availableFields: PropTypes.array, + onChange: PropTypes.func.isRequired, + isReservedRole: PropTypes.bool.isRequired, + allowDelete: PropTypes.bool.isRequired, + allowDocumentLevelSecurity: PropTypes.bool.isRequired, + allowFieldLevelSecurity: PropTypes.bool.isRequired, + validator: PropTypes.object.isRequired, + }; + + constructor(props) { + super(props); + this.state = { + queryExpanded: !!props.indexPrivilege.query, + documentQuery: props.indexPrivilege.query + }; + } + + render() { + return ( + + + + + {this.getPrivilegeForm()} + + {this.props.allowDelete && ( + + + + + + )} + + + ); + } + + getPrivilegeForm = () => { + return ( + + + + + + + + + + + + + {this.getGrantedFieldsControl()} + + + + + {this.getGrantedDocumentsControl()} + + ); + }; + + getGrantedFieldsControl = () => { + const { + allowFieldLevelSecurity, + availableFields, + indexPrivilege, + isReservedRole, + } = this.props; + + if (!allowFieldLevelSecurity) { + return null; + } + + const { grant = [] } = indexPrivilege.field_security || {}; + + if (allowFieldLevelSecurity) { + return ( + + + + + + + + ); + } + + return null; + } + + getGrantedDocumentsControl = () => { + const { + allowDocumentLevelSecurity, + indexPrivilege, + } = this.props; + + if (!allowDocumentLevelSecurity) { + return null; + } + + return ( + + {!this.props.isReservedRole && + + + + } + {this.state.queryExpanded && + + + + + + } + + ); + }; + + toggleDocumentQuery = () => { + const willToggleOff = this.state.queryExanded; + const willToggleOn = !willToggleOff; + + // If turning off, then save the current query in state so that we can restore it if the user changes their mind. + this.setState({ + queryExpanded: !this.state.queryExpanded, + documentQuery: willToggleOff ? this.props.indexPrivilege.query : this.state.documentQuery + }); + + // If turning off, then remove the query from the Index Privilege + if (willToggleOff) { + this.props.onChange({ + ...this.props.indexPrivilege, + query: '', + }); + } + + // If turning on, then restore the saved query if available + if (willToggleOn && !this.props.indexPrivilege.query && this.state.documentQuery) { + this.props.onChange({ + ...this.props.indexPrivilege, + query: this.state.documentQuery, + }); + } + }; + + onCreateIndexPatternOption = (option) => { + const newIndexPatterns = this.props.indexPrivilege.names.concat([option]); + + this.props.onChange({ + ...this.props.indexPrivilege, + names: newIndexPatterns, + }); + }; + + onIndexPatternsChange = (newPatterns) => { + this.props.onChange({ + ...this.props.indexPrivilege, + names: newPatterns.map(fromOption), + }); + }; + + onPrivilegeChange = (newPrivileges) => { + this.props.onChange({ + ...this.props.indexPrivilege, + privileges: newPrivileges.map(fromOption), + }); + }; + + onQueryChange = (e) => { + this.props.onChange({ + ...this.props.indexPrivilege, + query: e.target.value, + }); + }; + + onCreateGrantedField = (grant) => { + const newGrants = this.props.indexPrivilege.field_security.grant.concat([grant]); + + this.props.onChange({ + ...this.props.indexPrivilege, + field_security: { + ...this.props.indexPrivilege.field_security, + grant: newGrants, + }, + }); + }; + + onGrantedFieldsChange = (grantedFields) => { + this.props.onChange({ + ...this.props.indexPrivilege, + field_security: { + ...this.props.indexPrivilege.field_security, + grant: grantedFields.map(fromOption), + }, + }); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.test.js new file mode 100644 index 00000000000000..1fed539d2526dc --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privilege_form.test.js @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { IndexPrivilegeForm } from './index_privilege_form'; +import { RoleValidator } from '../../lib/validate_role'; +import { EuiSwitch, EuiTextArea, EuiButtonIcon } from '@elastic/eui'; + +test('it renders without crashing', () => { + const props = { + indexPrivilege: { + names: [], + privileges: [], + query: null, + field_security: { + grant: [] + } + }, + indexPatterns: [], + availableFields: [], + isReservedRole: false, + allowDelete: true, + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + onChange: jest.fn() + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); + +describe('delete button', () => { + const props = { + indexPrivilege: { + names: [], + privileges: [], + query: null, + field_security: { + grant: [] + } + }, + indexPatterns: [], + availableFields: [], + isReservedRole: false, + allowDelete: true, + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + onChange: jest.fn(), + onDelete: jest.fn() + }; + + test('it is hidden when allowDelete is false', () => { + const testProps = { + ...props, + allowDelete: false + }; + const wrapper = mount(); + expect(wrapper.find(EuiButtonIcon)).toHaveLength(0); + }); + + test('it is shown when allowDelete is true', () => { + const testProps = { + ...props, + allowDelete: true + }; + const wrapper = mount(); + expect(wrapper.find(EuiButtonIcon)).toHaveLength(1); + }); + + test('it invokes onDelete when clicked', () => { + const testProps = { + ...props, + allowDelete: true + }; + const wrapper = mount(); + wrapper.find(EuiButtonIcon).simulate('click'); + expect(testProps.onDelete).toHaveBeenCalledTimes(1); + }); +}); + +describe(`document level security`, () => { + const props = { + indexPrivilege: { + names: [], + privileges: [], + query: "some query", + field_security: { + grant: [] + } + }, + indexPatterns: [], + availableFields: [], + isReservedRole: false, + allowDelete: true, + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + onChange: jest.fn() + }; + + test(`inputs are hidden when DLS is not allowed`, () => { + const testProps = { + ...props, + allowDocumentLevelSecurity: false + }; + + const wrapper = mount(); + expect(wrapper.find(EuiSwitch)).toHaveLength(0); + expect(wrapper.find(EuiTextArea)).toHaveLength(0); + }); + + test('only the switch is shown when allowed, and query is empty', () => { + const testProps = { + ...props, + indexPrivilege: { + ...props.indexPrivilege, + query: null + } + }; + + const wrapper = mount(); + expect(wrapper.find(EuiSwitch)).toHaveLength(1); + expect(wrapper.find(EuiTextArea)).toHaveLength(0); + }); + + test('both inputs are shown when allowed, and query is not empty', () => { + const testProps = { + ...props, + }; + + const wrapper = mount(); + expect(wrapper.find(EuiSwitch)).toHaveLength(1); + expect(wrapper.find(EuiTextArea)).toHaveLength(1); + }); +}); + +describe('field level security', () => { + const props = { + indexPrivilege: { + names: [], + privileges: [], + query: null, + field_security: { + grant: ["foo*"] + } + }, + indexPatterns: [], + availableFields: [], + isReservedRole: false, + allowDelete: true, + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + onChange: jest.fn() + }; + + test(`input is hidden when FLS is not allowed`, () => { + const testProps = { + ...props, + allowFieldLevelSecurity: false + }; + + const wrapper = mount(); + expect(wrapper.find(".indexPrivilegeForm__grantedFieldsRow")).toHaveLength(0); + }); + + test('input is shown when allowed', () => { + const testProps = { + ...props, + }; + + const wrapper = mount(); + expect(wrapper.find("div.indexPrivilegeForm__grantedFieldsRow")).toHaveLength(1); + }); + + test('it displays a warning when no fields are granted', () => { + const testProps = { + ...props, + indexPrivilege: { + ...props.indexPrivilege, + field_security: { + grant: [] + } + } + }; + + const wrapper = mount(); + expect(wrapper.find("div.indexPrivilegeForm__grantedFieldsRow")).toHaveLength(1); + expect(wrapper.find(".euiFormHelpText")).toHaveLength(1); + }); + + test('it does not display a warning when fields are granted', () => { + const testProps = { + ...props + }; + + const wrapper = mount(); + expect(wrapper.find("div.indexPrivilegeForm__grantedFieldsRow")).toHaveLength(1); + expect(wrapper.find(".euiFormHelpText")).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.js new file mode 100644 index 00000000000000..cb72ecd7aa4bfd --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.js @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, { Component } from 'react'; +import PropTypes from 'prop-types'; +import _ from 'lodash'; +import { isReservedRole, isRoleEnabled } from '../../../../../lib/role'; +import { IndexPrivilegeForm } from './index_privilege_form'; +import { getFields } from '../../../../../objects'; + +export class IndexPrivileges extends Component { + static propTypes = { + role: PropTypes.object.isRequired, + indexPatterns: PropTypes.array.isRequired, + allowDocumentLevelSecurity: PropTypes.bool.isRequired, + allowFieldLevelSecurity: PropTypes.bool.isRequired, + httpClient: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + validator: PropTypes.object.isRequired, + } + + state = { + availableFields: {} + } + + componentDidMount() { + this.loadAvailableFields(this.props.role.indices); + } + + render() { + const { indices = [] } = this.props.role; + + const { + indexPatterns, + allowDocumentLevelSecurity, + allowFieldLevelSecurity + } = this.props; + + const props = { + indexPatterns, + // If editing an existing role while that has been disabled, always show the FLS/DLS fields because currently + // a role is only marked as disabled if it has FLS/DLS setup (usually before the user changed to a license that + // doesn't permit FLS/DLS). + allowDocumentLevelSecurity: allowDocumentLevelSecurity || !isRoleEnabled(this.props.role), + allowFieldLevelSecurity: allowFieldLevelSecurity || !isRoleEnabled(this.props.role), + isReservedRole: isReservedRole(this.props.role) + }; + + const forms = indices.map((indexPrivilege, idx) => ( + + )); + + return forms; + } + + addIndexPrivilege = () => { + const { role } = this.props; + + const newIndices = [...role.indices, { + names: [], + privileges: [], + field_security: { + grant: ['*'] + } + }]; + + this.props.onChange({ + ...this.props.role, + indices: newIndices + }); + }; + + onIndexPrivilegeChange = (privilegeIndex) => { + return (updatedPrivilege) => { + const { role } = this.props; + const { indices } = role; + + const newIndices = [...indices]; + newIndices[privilegeIndex] = updatedPrivilege; + + this.props.onChange({ + ...this.props.role, + indices: newIndices + }); + + this.loadAvailableFields(newIndices); + }; + }; + + onIndexPrivilegeDelete = (privilegeIndex) => { + return () => { + const { role } = this.props; + + const newIndices = [...role.indices]; + newIndices.splice(privilegeIndex, 1); + + this.props.onChange({ + ...this.props.role, + indices: newIndices + }); + }; + } + + isPlaceholderPrivilege = (indexPrivilege) => { + return indexPrivilege.names.length === 0; + }; + + loadAvailableFields(indices) { + // Reserved roles cannot be edited, and therefore do not need to fetch available fields. + if (isReservedRole(this.props.role)) { + return; + } + + const patterns = indices.map(index => index.names.join(',')); + + const cachedPatterns = Object.keys(this.state.availableFields); + const patternsToFetch = _.difference(patterns, cachedPatterns); + + const fetchRequests = patternsToFetch.map(this.loadFieldsForPattern); + + Promise.all(fetchRequests) + .then(response => { + + this.setState({ + availableFields: { + ...this.state.availableFields, + ...response.reduce((acc, o) => ({ ...acc, ...o }), {}) + } + }); + }); + } + + loadFieldsForPattern = async (pattern) => { + if (!pattern) return { [pattern]: [] }; + + try { + return { + [pattern]: await getFields(this.props.httpClient, pattern) + }; + + } catch (e) { + return { + [pattern]: [] + }; + } + } +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.test.js new file mode 100644 index 00000000000000..221d301ceef0f3 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index_privileges.test.js @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { IndexPrivileges } from './index_privileges'; +import { IndexPrivilegeForm } from './index_privilege_form'; +import { RoleValidator } from '../../lib/validate_role'; + +test('it renders without crashing', () => { + const props = { + role: { + cluster: [], + indices: [], + run_as: [] + }, + httpClient: jest.fn(), + onChange: jest.fn(), + indexPatterns: [], + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); + +test('it renders a IndexPrivilegeForm for each privilege on the role', () => { + const props = { + role: { + cluster: [], + indices: [{ + names: ['foo*'], + privileges: ['all'], + query: '*', + field_security: { + grant: ['some_field'] + } + }], + run_as: [] + }, + httpClient: jest.fn(), + onChange: jest.fn(), + indexPatterns: [], + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + }; + const wrapper = mount(); + expect(wrapper.find(IndexPrivilegeForm)).toHaveLength(1); +}); \ No newline at end of file diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana_privileges.js b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana_privileges.js new file mode 100644 index 00000000000000..a8c2a2c52cbe00 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana_privileges.js @@ -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 React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { isReservedRole } from '../../../../../lib/role'; +import { getKibanaPrivileges } from '../../lib/get_application_privileges'; +import { setApplicationPrivileges } from '../../lib/set_application_privileges'; + +import { CollapsiblePanel } from '../collapsible_panel'; +import { + EuiSelect, + EuiDescribedFormGroup, + EuiFormRow, +} from '@elastic/eui'; + +const noPrivilegeValue = '-none-'; + +export class KibanaPrivileges extends Component { + static propTypes = { + role: PropTypes.object.isRequired, + spaces: PropTypes.array, + kibanaAppPrivileges: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + }; + + idPrefix = () => `${this.props.rbacApplication}_`; + + privilegeToId = (privilege) => `${this.idPrefix()}${privilege}`; + + idToPrivilege = (id) => id.split(this.idPrefix())[1]; + + render() { + return ( + + {this.getForm()} + + ); + } + + getForm = () => { + const { + kibanaAppPrivileges, + role, + rbacApplication + } = this.props; + + const kibanaPrivileges = getKibanaPrivileges(kibanaAppPrivileges, role, rbacApplication); + + const options = [ + { value: noPrivilegeValue, text: 'none' }, + ...Object.keys(kibanaPrivileges).map(p => ({ + value: p, + text: p + })) + ]; + + const value = Object.keys(kibanaPrivileges).find(p => kibanaPrivileges[p]) || noPrivilegeValue; + + return ( + Application privileges

} + description={

Manage the actions this role can perform within Kibana.

} + > + + + +
+ ); + } + + onKibanaPrivilegesChange = (e) => { + const role = { + ...this.props.role, + applications: [...this.props.role.applications] + }; + + const privilege = e.target.value; + + if (privilege === noPrivilegeValue) { + // unsetting all privileges -- only necessary until RBAC Phase 3 + const noPrivileges = {}; + setApplicationPrivileges(noPrivileges, role, this.props.rbacApplication); + } else { + const newPrivileges = { + [privilege]: true + }; + setApplicationPrivileges(newPrivileges, role, this.props.rbacApplication); + } + + this.props.onChange(role); + } +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.js b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.js new file mode 100644 index 00000000000000..8b750b19338ed4 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.js @@ -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 React from 'react'; +import PropTypes from 'prop-types'; + +import { isReservedRole } from '../../../../lib/role'; +import { + EuiIcon, + EuiToolTip, +} from '@elastic/eui'; + + +export const ReservedRoleBadge = (props) => { + const { + role + } = props; + + if (isReservedRole(role)) { + return ( + + + + ); + } + return null; +}; + +ReservedRoleBadge.propTypes = { + role: PropTypes.object.isRequired +}; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.js b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.js new file mode 100644 index 00000000000000..939348661a7417 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.js @@ -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 React from 'react'; +import { + EuiIcon +} from '@elastic/eui'; +import { ReservedRoleBadge } from './reserved_role_badge'; +import { + shallow +} from 'enzyme'; + +const reservedRole = { + metadata: { + _reserved: true + } +}; + +const unreservedRole = {}; + +test('it renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiIcon)).toHaveLength(1); +}); + +test('it renders nothing for an unreserved role', () => { + const wrapper = shallow(); + expect(wrapper.find('*')).toHaveLength(0); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/edit_role.html b/x-pack/plugins/security/public/views/management/edit_role/edit_role.html new file mode 100644 index 00000000000000..5539f0521dd79c --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/edit_role.html @@ -0,0 +1 @@ +
diff --git a/x-pack/plugins/security/public/views/management/edit_role/edit_role.less b/x-pack/plugins/security/public/views/management/edit_role/edit_role.less new file mode 100644 index 00000000000000..d4f7ac04880d60 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/edit_role.less @@ -0,0 +1,10 @@ +#editRoleReactRoot { + background: #f5f5f5; + flex-grow: 1; +} + +.editRolePage { + max-width: 1000px; + margin-left: auto; + margin-right: auto; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/index.js b/x-pack/plugins/security/public/views/management/edit_role/index.js new file mode 100644 index 00000000000000..e356b9d7c01b6e --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/index.js @@ -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 _ from 'lodash'; +import routes from 'ui/routes'; +import { fatalError } from 'ui/notify'; +import template from 'plugins/security/views/management/edit_role/edit_role.html'; +import 'plugins/security/views/management/edit_role/edit_role.less'; +import 'angular-ui-select'; +import 'plugins/security/services/application_privilege'; +import 'plugins/security/services/shield_user'; +import 'plugins/security/services/shield_role'; +import 'plugins/security/services/shield_privileges'; +import 'plugins/security/services/shield_indices'; + +import { IndexPatternsProvider } from 'ui/index_patterns/index_patterns'; +import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; +import { checkLicenseError } from 'plugins/security/lib/check_license_error'; +import { EDIT_ROLES_PATH, ROLES_PATH } from '../management_urls'; + +import { EditRolePage } from './components'; + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; + +routes.when(`${EDIT_ROLES_PATH}/:name?`, { + template, + resolve: { + role($route, ShieldRole, kbnUrl, Promise, Notifier) { + const name = $route.current.params.name; + + let role; + + if (name != null) { + role = ShieldRole.get({ name }).$promise + .catch((response) => { + + if (response.status !== 404) { + return fatalError(response); + } + + const notifier = new Notifier(); + notifier.error(`No "${name}" role found.`); + kbnUrl.redirect(ROLES_PATH); + return Promise.halt(); + }); + + } else { + role = Promise.resolve(new ShieldRole({ + cluster: [], + indices: [], + run_as: [], + applications: [] + })); + } + + return role.then(res => res.toJSON()); + }, + kibanaApplicationPrivilege(ApplicationPrivilege, kbnUrl, Promise, Private) { + return ApplicationPrivilege.query().$promise + .then(privileges => privileges.map(p => p.toJSON())) + .catch(checkLicenseError(kbnUrl, Promise, Private)); + }, + users(ShieldUser, kbnUrl, Promise, Private) { + // $promise is used here because the result is an ngResource, not a promise itself + return ShieldUser.query().$promise + .then(users => _.map(users, 'username')) + .catch(checkLicenseError(kbnUrl, Promise, Private)); + }, + indexPatterns(Private) { + const indexPatterns = Private(IndexPatternsProvider); + return indexPatterns.getTitles(); + } + }, + controllerAs: 'editRole', + controller($injector, $scope, $http, rbacEnabled, rbacApplication) { + const $route = $injector.get('$route'); + const Private = $injector.get('Private'); + + const Notifier = $injector.get('Notifier'); + + const kibanaApplicationPrivilege = $route.current.locals.kibanaApplicationPrivilege; + const role = $route.current.locals.role; + + const xpackInfo = Private(XPackInfoProvider); + const allowDocumentLevelSecurity = xpackInfo.get('features.security.allowRoleDocumentLevelSecurity'); + const allowFieldLevelSecurity = xpackInfo.get('features.security.allowRoleFieldLevelSecurity'); + + const domNode = document.getElementById('editRoleReactRoot'); + + const { + users, + indexPatterns, + } = $route.current.locals; + + const routeBreadcrumbs = routes.getBreadcrumbs(); + + render(, domNode); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + unmountComponentAtNode(domNode); + }); + } +}); + +function transformBreadcrumbs(routeBreadcrumbs) { + const indexOfEdit = routeBreadcrumbs.findIndex(b => b.id === 'edit'); + + const hasEntryAfterEdit = indexOfEdit >= 0 && indexOfEdit < (routeBreadcrumbs.length - 1); + + if (hasEntryAfterEdit) { + // The entry after 'edit' is the name of the role being edited (if any). We don't want to use the "humanized" version of the role name here + const roleName = routeBreadcrumbs[indexOfEdit + 1]; + roleName.display = roleName.id; + } + + return routeBreadcrumbs.filter(b => b.id !== 'edit'); +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/validate_role.test.js.snap b/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/validate_role.test.js.snap new file mode 100644 index 00000000000000..20b4280b7f493b --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/validate_role.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`validateIndexPrivileges it throws when indices is not an array 1`] = `"Expected role.indices to be an array"`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/get_application_privileges.js b/x-pack/plugins/security/public/views/management/edit_role/lib/get_application_privileges.js new file mode 100644 index 00000000000000..1ee8d201806b69 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/get_application_privileges.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import _ from 'lodash'; + +export function getKibanaPrivileges(kibanaApplicationPrivilege, role, application) { + const kibanaPrivileges = kibanaApplicationPrivilege.reduce((acc, p) => { + acc[p.name] = false; + return acc; + }, {}); + + if (!role.applications || role.applications.length === 0) { + return kibanaPrivileges; + } + + const applications = role.applications.filter(x => x.application === application); + + const assigned = _.uniq(_.flatten(_.pluck(applications, 'privileges'))); + assigned.forEach(a => { + kibanaPrivileges[a] = true; + }); + + return kibanaPrivileges; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/set_application_privileges.js b/x-pack/plugins/security/public/views/management/edit_role/lib/set_application_privileges.js new file mode 100644 index 00000000000000..e6e133fbf69bd5 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/set_application_privileges.js @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { DEFAULT_RESOURCE } from '../../../../../common/constants'; + +export function setApplicationPrivileges(kibanaPrivileges, role, application) { + if (!role.applications) { + role.applications = []; + } + + // we first remove the matching application entries + role.applications = role.applications.filter(x => { + return x.application !== application; + }); + + const privileges = Object.keys(kibanaPrivileges).filter(key => kibanaPrivileges[key]); + + // if we still have them, put the application entry back + if (privileges.length > 0) { + role.applications = [...role.applications, { + application, + privileges, + resources: [DEFAULT_RESOURCE] + }]; + } +} + +export function togglePrivilege(role, application, permission) { + const appPermissions = role.applications + .find(a => a.application === application && a.resources[0] === DEFAULT_RESOURCE); + + if (!appPermissions) { + role.applications.push({ + application, + privileges: [permission], + resources: [DEFAULT_RESOURCE] + }); + } else { + const indexOfExisting = appPermissions.privileges.indexOf(permission); + if (indexOfExisting >= 0) { + appPermissions.privileges.splice(indexOfExisting, 1); + } else { + appPermissions.privileges.push(permission); + } + } +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.js b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.js new file mode 100644 index 00000000000000..3fc638a83782cb --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.js @@ -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. + */ + +export class RoleValidator { + constructor(options = {}) { + this._shouldValidate = options.shouldValidate; + } + + enableValidation() { + this._shouldValidate = true; + } + + disableValidation() { + this._shouldValidate = false; + } + + validateRoleName(role) { + if (!this._shouldValidate) return valid(); + + if (!role.name) { + return invalid(`Please provide a role name`); + } + if (role.name.length > 1024) { + return invalid(`Name must not exceed 1024 characters`); + } + if (!role.name.match(/^[a-zA-Z_][a-zA-Z0-9_@\-\$\.]*$/)) { + return invalid(`Name must begin with a letter or underscore and contain only letters, underscores, and numbers.`); + } + return valid(); + } + + validateIndexPrivileges(role) { + if (!this._shouldValidate) return valid(); + + if (!Array.isArray(role.indices)) { + throw new TypeError(`Expected role.indices to be an array`); + } + + const areIndicesValid = role.indices + .map(this.validateIndexPrivilege.bind(this)) + .find((result) => result.isInvalid) == null; + + if (areIndicesValid) { + return valid(); + } + return invalid(); + } + + validateIndexPrivilege(indexPrivilege) { + if (!this._shouldValidate) return valid(); + + if (indexPrivilege.names.length && !indexPrivilege.privileges.length) { + return invalid(`At least one privilege is required`); + } + return valid(); + } + + validateForSave(role) { + const { isInvalid: isNameInvalid } = this.validateRoleName(role); + const { isInvalid: areIndicesInvalid } = this.validateIndexPrivileges(role); + + if (isNameInvalid || areIndicesInvalid) { + return invalid(); + } + + return valid(); + } + +} + +function invalid(error) { + return { + isInvalid: true, + error + }; +} + +function valid() { + return { + isInvalid: false + }; +} + diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.js b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.js new file mode 100644 index 00000000000000..47d2bfe59a37e6 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.js @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { RoleValidator } from "./validate_role"; + +let validator; + +describe('validateRoleName', () => { + beforeEach(() => { + validator = new RoleValidator({ shouldValidate: true }); + }); + + test('it allows an alphanumeric role name', () => { + const role = { + name: 'This-is-30-character-role-name' + }; + + expect(validator.validateRoleName(role)).toEqual({ isInvalid: false }); + }); + + test('it requires a non-empty value', () => { + const role = { + name: '' + }; + + expect(validator.validateRoleName(role)).toEqual({ isInvalid: true, error: `Please provide a role name` }); + }); + + test('it cannot exceed 1024 characters', () => { + const role = { + name: new Array(1026).join('A') + }; + + expect(validator.validateRoleName(role)).toEqual({ isInvalid: true, error: `Name must not exceed 1024 characters` }); + }); + + const charList = `!#%^&*()+=[]{}\|';:"/,<>?`.split(''); + charList.forEach(element => { + test(`it cannot support the "${element}" character`, () => { + const role = { + name: `role-${element}` + }; + + expect(validator.validateRoleName(role)).toEqual( + { + isInvalid: true, + error: `Name must begin with a letter or underscore and contain only letters, underscores, and numbers.` + } + ); + }); + }); +}); + +describe('validateIndexPrivileges', () => { + beforeEach(() => { + validator = new RoleValidator({ shouldValidate: true }); + }); + + test('it ignores privilegs with no indices defined', () => { + const role = { + indices: [{ + names: [], + privileges: [] + }] + }; + + expect(validator.validateIndexPrivileges(role)).toEqual({ + isInvalid: false + }); + }); + + test('it requires privilges when an index is defined', () => { + const role = { + indices: [{ + names: ['index-*'], + privileges: [] + }] + }; + + expect(validator.validateIndexPrivileges(role)).toEqual({ + isInvalid: true + }); + }); + + test('it throws when indices is not an array', () => { + const role = { + indices: null + }; + + expect(() => validator.validateIndexPrivileges(role)).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.html b/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.html deleted file mode 100644 index 829b0dfaefa926..00000000000000 --- a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.html +++ /dev/null @@ -1,128 +0,0 @@ -
- - - -
-
- -
- -
- - - {{$item}} - -
-
-
- - -
- - - - {{$item}} - -
-
-
- - -
- Indices must contain at least one privilege. -
- - -
- - - -
-
- - -
- -
- - - - -
- - - - {{$item}} - -
-
-
- -
-
- - - -
-
-
-
-
-
diff --git a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.js b/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.js deleted file mode 100644 index 1bb058f56d0960..00000000000000 --- a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import { uiModules } from 'ui/modules'; -import template from './index_privileges_form.html'; - -const module = uiModules.get('security', ['kibana']); -module.directive('kbnIndexPrivilegesForm', function () { - return { - template, - scope: { - isNewRole: '=', - indices: '=', - indexPatterns: '=', - privileges: '=', - fieldOptions: '=', - isReserved: '=', - isEnabled: '=', - allowDocumentLevelSecurity: '=', - allowFieldLevelSecurity: '=', - addIndex: '&', - removeIndex: '&', - }, - restrict: 'E', - replace: true, - controllerAs: 'indexPrivilegesController', - controller: function ($scope) { - this.addIndex = function addIndex() { - $scope.addIndex({ indices: $scope.indices }); - }; - - this.removeIndex = function removeIndex(index) { - $scope.removeIndex({ indices: $scope.indices, index }); - }; - - this.getIndexTitle = function getIndexTitle(index) { - const indices = index.names.length ? index.names.join(', ') : 'No indices'; - const privileges = index.privileges.length ? index.privileges.join(', ') : 'No privileges'; - return `${indices} (${privileges})`; - }; - - this.union = _.flow(_.union, _.compact); - - // If editing an existing role while that has been disabled, always show the FLS/DLS fields because currently - // a role is only marked as disabled if it has FLS/DLS setup (usually before the user changed to a license that - // doesn't permit FLS/DLS). - if (!$scope.isNewRole && !$scope.isEnabled) { - this.showDocumentLevelSecurity = true; - this.showFieldLevelSecurity = true; - } else { - this.showDocumentLevelSecurity = $scope.allowDocumentLevelSecurity; - this.showFieldLevelSecurity = $scope.allowFieldLevelSecurity; - } - }, - }; -}); diff --git a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.less b/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.less deleted file mode 100644 index edd7a4898f45a0..00000000000000 --- a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.less +++ /dev/null @@ -1,7 +0,0 @@ -.indexPrivilegesForm { - height: 550px; -} - -.indexPrivilegesList { - flex: 0 0 400px; -} diff --git a/x-pack/plugins/security/public/views/management/management.js b/x-pack/plugins/security/public/views/management/management.js index b2bd09e67e9bf1..f7000478cbfc7f 100644 --- a/x-pack/plugins/security/public/views/management/management.js +++ b/x-pack/plugins/security/public/views/management/management.js @@ -5,12 +5,11 @@ */ import 'plugins/security/views/management/change_password_form/change_password_form'; -import 'plugins/security/views/management/index_privileges_form/index_privileges_form'; import 'plugins/security/views/management/password_form/password_form'; import 'plugins/security/views/management/users'; import 'plugins/security/views/management/roles'; import 'plugins/security/views/management/edit_user'; -import 'plugins/security/views/management/edit_role'; +import 'plugins/security/views/management/edit_role/index'; import 'plugins/security/views/management/management.less'; import routes from 'ui/routes'; import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; diff --git a/x-pack/plugins/security/public/views/management/management.less b/x-pack/plugins/security/public/views/management/management.less index d9e16650aeecce..14fc8f4fa13248 100644 --- a/x-pack/plugins/security/public/views/management/management.less +++ b/x-pack/plugins/security/public/views/management/management.less @@ -1,5 +1,4 @@ @import '~plugins/xpack_main/style/main.less'; -@import './index_privileges_form/index_privileges_form'; .kuiFormFooter { display: flex; diff --git a/x-pack/plugins/security/server/lib/check_user_permission.js b/x-pack/plugins/security/server/lib/check_user_permission.js deleted file mode 100644 index 6890a6fc790929..00000000000000 --- a/x-pack/plugins/security/server/lib/check_user_permission.js +++ /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. - */ - -/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 async function checkUserPermission(permission, hasPermission) { - // POC Stub. TODO: Use ES Permissions API once implemented. - return Promise.resolve(hasPermission); -} diff --git a/x-pack/plugins/security/server/lib/privileges/privileges.js b/x-pack/plugins/security/server/lib/privileges/privileges.js index e05be3117f934a..129d4b3abfb3ea 100644 --- a/x-pack/plugins/security/server/lib/privileges/privileges.js +++ b/x-pack/plugins/security/server/lib/privileges/privileges.js @@ -45,7 +45,7 @@ function buildSavedObjectsReadPrivileges() { } function buildSavedObjectsPrivileges(actions) { - const objectTypes = ['config', 'dashboard', 'index-pattern', 'search', 'visualization', 'graph-workspace']; + const objectTypes = ['config', 'dashboard', 'graph-workspace', 'index-pattern', 'search', 'timelion-sheet', 'url', 'visualization']; return objectTypes .map(type => actions.map(action => `action:saved-objects/${type}/${action}`)) .reduce((acc, types) => [...acc, ...types], []); diff --git a/x-pack/plugins/spaces/common/index.js b/x-pack/plugins/spaces/common/index.js new file mode 100644 index 00000000000000..1cd6907d3d64c1 --- /dev/null +++ b/x-pack/plugins/spaces/common/index.js @@ -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 { isReservedSpace } from './is_reserved_space'; \ No newline at end of file diff --git a/x-pack/plugins/spaces/index.js b/x-pack/plugins/spaces/index.js index 603b8ddb22e11c..38d2e84cc992f9 100644 --- a/x-pack/plugins/spaces/index.js +++ b/x-pack/plugins/spaces/index.js @@ -52,7 +52,7 @@ export const spaces = (kibana) => new kibana.Plugin({ valid: true, space: await getActiveSpace(request.getSavedObjectsClient(), request.getBasePath()) }; - } catch(e) { + } catch (e) { vars.activeSpace = { valid: false, error: wrapError(e).output.payload diff --git a/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap b/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap index 936f678055d413..c7cbfdb53fd737 100644 --- a/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap +++ b/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap @@ -1,15 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`it renders without crashing 1`] = ` -
-
+ -
-
-
+ + `; diff --git a/x-pack/plugins/spaces/public/views/management/components/manage_space_page.js b/x-pack/plugins/spaces/public/views/management/components/manage_space_page.js index 2262e597fb433b..2207076c3e695b 100644 --- a/x-pack/plugins/spaces/public/views/management/components/manage_space_page.js +++ b/x-pack/plugins/spaces/public/views/management/components/manage_space_page.js @@ -25,6 +25,8 @@ import { DeleteSpacesButton } from './delete_spaces_button'; import { Notifier, toastNotifications } from 'ui/notify'; import { UrlContext } from './url_context'; import { toUrlContext, isValidUrlContext } from '../lib/url_context_utils'; +import { isReservedSpace } from '../../../../common'; +import { ReservedSpaceBadge } from './reserved_space_badge'; export class ManageSpacePage extends React.Component { state = { @@ -71,12 +73,7 @@ export class ManageSpacePage extends React.Component { - - -

{this.getTitle()}

-
- {this.getActionButton()} -
+ {this.getFormHeading()} @@ -108,7 +105,7 @@ export class ManageSpacePage extends React.Component { @@ -128,6 +125,19 @@ export class ManageSpacePage extends React.Component { ); } + getFormHeading = () => { + const isReserved = isReservedSpace(this.state.space); + + return ( + + +

{this.getTitle()}

+
+ {isReserved ? this.getReservedBadge() : this.getActionButton()} +
+ ); + }; + getTitle = () => { if (this.editingExistingSpace()) { return `Edit space`; @@ -135,8 +145,10 @@ export class ManageSpacePage extends React.Component { return `Create a space`; }; + getReservedBadge = () => ; + getActionButton = () => { - if (this.editingExistingSpace()) { + if (this.editingExistingSpace() && !isReservedSpace(this.state.space)) { return ( - - {this.props.breadcrumbs.map(this.buildBreadcrumb)} - + ); } buildBreadcrumb = (breadcrumb) => { - return ( - - {breadcrumb.display} - - ); + return { + text: breadcrumb.display, + href: breadcrumb.href, + }; } } - - PageHeader.propTypes = { breadcrumbs: PropTypes.array.isRequired -}; +}; \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/management/components/page_header.test.js b/x-pack/plugins/spaces/public/views/management/components/page_header.test.js index b960175b38debd..706a6de4a0abc1 100644 --- a/x-pack/plugins/spaces/public/views/management/components/page_header.test.js +++ b/x-pack/plugins/spaces/public/views/management/components/page_header.test.js @@ -6,30 +6,28 @@ import React from 'react'; import { PageHeader } from './page_header'; -import { render } from 'enzyme'; -import renderer from 'react-test-renderer'; +import { mount, shallow } from 'enzyme'; test('it renders without crashing', () => { - const component = renderer.create( + const component = shallow( ); expect(component).toMatchSnapshot(); }); test('it renders breadcrumbs', () => { - const component = render( + const component = mount( ); - expect(component.find('a')).toHaveLength(2); + expect(component.find('a.euiBreadcrumb')).toHaveLength(1); + expect(component.find('span.euiBreadcrumb')).toHaveLength(1); }); diff --git a/x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.js b/x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.js new file mode 100644 index 00000000000000..0ea7e05d77e12a --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { isReservedSpace } from '../../../../common'; +import { + EuiBadge, + EuiToolTip, + EuiFlexItem, +} from '@elastic/eui'; + + +export const ReservedSpaceBadge = (props) => { + const { + space + } = props; + + if (isReservedSpace(space)) { + return ( + + + Reserved Space + + + ); + } + return null; +}; + +ReservedSpaceBadge.propTypes = { + space: PropTypes.object.isRequired +}; diff --git a/x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.test.js b/x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.test.js new file mode 100644 index 00000000000000..e45926b8598246 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.test.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiBadge +} from '@elastic/eui'; +import { ReservedSpaceBadge } from './reserved_space_badge'; +import { + shallow +} from 'enzyme'; + +const reservedSpace = { + _reserved: true +}; + +const unreservedSpace = {}; + +test('it renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiBadge)).toHaveLength(1); +}); + +test('it renders nothing for an unreserved space', () => { + const wrapper = shallow(); + expect(wrapper.find('*')).toHaveLength(0); +}); diff --git a/x-pack/plugins/spaces/public/views/management/components/url_context.js b/x-pack/plugins/spaces/public/views/management/components/url_context.js index 418a51b29347e4..6253e36d0f9e2e 100644 --- a/x-pack/plugins/spaces/public/views/management/components/url_context.js +++ b/x-pack/plugins/spaces/public/views/management/components/url_context.js @@ -35,7 +35,7 @@ export class UrlContext extends Component {
this.textFieldRef = ref} @@ -48,6 +48,10 @@ export class UrlContext extends Component { } getLabel = () => { + if (!this.props.editable) { + return (

URL Context

); + } + const editLinkText = this.state.editing ? `[stop editing]` : `[edit]`; return (

URL Context {editLinkText}

); }; diff --git a/x-pack/plugins/spaces/public/views/management/manage_spaces.less b/x-pack/plugins/spaces/public/views/management/manage_spaces.less index a9696d0c2f824e..ae515cbb829fa1 100644 --- a/x-pack/plugins/spaces/public/views/management/manage_spaces.less +++ b/x-pack/plugins/spaces/public/views/management/manage_spaces.less @@ -1,7 +1,7 @@ -.application, .euiPanel { +.manageSpaces__application, .manageSpaces__.euiPanel { background: #f5f5f5 } -.euiPage { +.manageSpaces__euiPage { padding: 0; } diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index fb4184fbbb6322..7c39d6e8fae06e 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -10,9 +10,9 @@ esutils "^2.0.2" js-tokens "^3.0.0" -"@elastic/eui@0.0.47": - version "0.0.47" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-0.0.47.tgz#5bae27966bb1d68bb3106853610a407509053b44" +"@elastic/eui@v0.0.51": + version "0.0.51" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-0.0.51.tgz#5d809af270dd9994a609fd01eaa84e21a62fff98" dependencies: brace "^0.10.0" classnames "^2.2.5" @@ -990,8 +990,8 @@ brace@0.10.0, brace@^0.10.0: w3c-blob "0.0.1" brace@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/brace/-/brace-0.11.0.tgz#155cd80607687dc8cb908f0df94e62a033c1d563" + version "0.11.1" + resolved "https://registry.yarnpkg.com/brace/-/brace-0.11.1.tgz#4896fcc9d544eef45f4bb7660db320d3b379fe58" braces@^1.8.2: version "1.8.5" @@ -1072,6 +1072,10 @@ buffer-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe" +buffer-from@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz#87fcaa3a298358e0ade6e442cfce840740d1ad04" + buffer@^3.0.1: version "3.6.0" resolved "https://registry.yarnpkg.com/buffer/-/buffer-3.6.0.tgz#a72c936f77b96bf52f5f7e7b467180628551defb" @@ -1400,7 +1404,16 @@ concat-stream@1.5.1: readable-stream "~2.0.0" typedarray "~0.0.5" -concat-stream@^1.4.7, concat-stream@~1.6.0: +concat-stream@^1.4.7: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +concat-stream@~1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.0.tgz#0aac662fd52be78964d5532f694784e70110acf7" dependencies: @@ -1443,8 +1456,8 @@ core-js@^2.4.0, core-js@^2.5.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e" core-js@^2.5.1: - version "2.5.5" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.5.tgz#b14dde936c640c0579a6b50cabcc132dd6127e3b" + version "2.5.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -2470,8 +2483,8 @@ focus-trap-react@^3.0.4, focus-trap-react@^3.1.1: focus-trap "^2.0.1" focus-trap@^2.0.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.4.2.tgz#44ea1c55a9c22c2b6529dcebbde6390eb2ee4c88" + version "2.4.5" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.4.5.tgz#91c9c9ffb907f8f4446d80202dda9c12c2853ddb" dependencies: tabbable "^1.0.3" @@ -3316,10 +3329,16 @@ icalendar@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/icalendar/-/icalendar-0.7.1.tgz#d0d3486795f8f1c5cf4f8cafac081b4b4e7a32ae" -iconv-lite@0.4.19, iconv-lite@^0.4.19, iconv-lite@~0.4.13: +iconv-lite@0.4.19, iconv-lite@^0.4.19: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" +iconv-lite@~0.4.13: + version "0.4.23" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" + dependencies: + safer-buffer ">= 2.1.2 < 3" + ieee754@^1.1.4: version "1.1.8" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" @@ -4513,7 +4532,11 @@ lodash@3.10.1, lodash@^3.10.0, lodash@^3.10.1: version "3.10.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" -lodash@^4.0.1, lodash@^4.13.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1: +lodash@^4.0.1: + version "4.17.10" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7" + +lodash@^4.13.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" @@ -5539,7 +5562,7 @@ prop-types@15.5.8: dependencies: fbjs "^0.8.9" -prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0: +prop-types@^15.5.0, prop-types@^15.5.4, prop-types@^15.6.0: version "15.6.0" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" dependencies: @@ -5547,7 +5570,7 @@ prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, loose-envify "^1.3.1" object-assign "^4.1.1" -prop-types@^15.6.1: +prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1: version "15.6.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.1.tgz#36644453564255ddda391191fb3a125cbdf654ca" dependencies: @@ -5710,8 +5733,8 @@ react-clipboard.js@^1.1.2: prop-types "^15.5.0" react-color@^2.13.8: - version "2.13.8" - resolved "https://registry.yarnpkg.com/react-color/-/react-color-2.13.8.tgz#bcc58f79a722b9bfc37c402e68cd18f26970aee4" + version "2.14.1" + resolved "https://registry.yarnpkg.com/react-color/-/react-color-2.14.1.tgz#db8ad4f45d81e74896fc2e1c99508927c6d084e0" dependencies: lodash "^4.0.1" material-colors "^1.2.1" @@ -5743,6 +5766,10 @@ react-input-autosize@^2.1.2, react-input-autosize@^2.2.1: dependencies: prop-types "^15.5.8" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + react-markdown-renderer@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/react-markdown-renderer/-/react-markdown-renderer-1.4.0.tgz#f3b95bd9fc7f7bf8ab3f0150aa696b41740e7d01" @@ -5855,14 +5882,15 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.2.0: prop-types "^15.6.0" react-virtualized@^9.18.5: - version "9.18.5" - resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.18.5.tgz#42dd390ebaa7ea809bfcaf775d39872641679b89" + version "9.19.1" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.19.1.tgz#84b53253df2d9df61c85ce037141edccc70a73fd" dependencies: babel-runtime "^6.26.0" classnames "^2.2.3" dom-helpers "^2.4.0 || ^3.0.0" loose-envify "^1.3.0" prop-types "^15.6.0" + react-lifecycles-compat "^3.0.4" react-vis@^1.8.1: version "1.8.2" @@ -5924,7 +5952,7 @@ read-pkg@^1.0.0: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2: +readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5: version "2.3.3" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c" dependencies: @@ -5936,6 +5964,18 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable string_decoder "~1.0.3" util-deprecate "~1.0.1" +readable-stream@^2.2.2: + version "2.3.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@^2.3.3, readable-stream@^2.3.5: version "2.3.5" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.5.tgz#b4f85003a938cbb6ecbce2a124fb1012bd1a838d" @@ -6335,10 +6375,18 @@ rxjs@5.3.0: dependencies: symbol-observable "^1.0.1" -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1: +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + samsam@1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" @@ -6739,6 +6787,12 @@ string_decoder@~1.0.3: dependencies: safe-buffer "~5.1.0" +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + dependencies: + safe-buffer "~5.1.0" + stringstream@~0.0.4, stringstream@~0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" @@ -6898,8 +6952,8 @@ tabbable@1.1.0: resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.0.tgz#2c9a9c9f09db5bb0659f587d532548dd6ef2067b" tabbable@^1.0.3, tabbable@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.2.tgz#b171680aea6e0a3e9281ff23532e2e5de11c0d94" + version "1.1.3" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.3.tgz#0e4ee376f3631e42d7977a074dbd2b3827843081" tar-fs@1.13.0: version "1.13.0" @@ -7160,8 +7214,8 @@ typedarray@^0.0.6, typedarray@~0.0.5: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" ua-parser-js@^0.7.9: - version "0.7.17" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac" + version "0.7.18" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed" uglify-js@^2.6: version "2.8.29" @@ -7293,10 +7347,14 @@ uuid@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1" -uuid@^3.0.0, uuid@^3.1.0: +uuid@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" +uuid@^3.1.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.2.1.tgz#12c528bb9d58d0b9265d9a2f6f0fe8be17ff1f14" + v8flags@^2.0.2: version "2.1.1" resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4" @@ -7489,8 +7547,8 @@ whatwg-encoding@^1.0.1: iconv-lite "0.4.19" whatwg-fetch@>=0.10.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84" + version "2.0.4" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" whatwg-url@^6.3.0: version "6.4.0" diff --git a/yarn.lock b/yarn.lock index 2098dda85233c6..459cd99a1d3eaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -77,9 +77,9 @@ version "0.0.0" uid "" -"@elastic/eui@0.0.47", "@elastic/eui@v0.0.47": - version "0.0.47" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-0.0.47.tgz#5bae27966bb1d68bb3106853610a407509053b44" +"@elastic/eui@v0.0.51": + version "0.0.51" + resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-0.0.51.tgz#5d809af270dd9994a609fd01eaa84e21a62fff98" dependencies: brace "^0.10.0" classnames "^2.2.5" @@ -1860,6 +1860,10 @@ buffer-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe" +buffer-from@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz#87fcaa3a298358e0ade6e442cfce840740d1ad04" + buffer-xor@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" @@ -2576,7 +2580,16 @@ concat-stream@1.6.0: readable-stream "^2.2.2" typedarray "^0.0.6" -concat-stream@^1.4.7, concat-stream@^1.5.2, concat-stream@^1.6.0, concat-stream@~1.6.0: +concat-stream@^1.4.7: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +concat-stream@^1.5.2, concat-stream@^1.6.0, concat-stream@~1.6.0: version "1.6.1" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.1.tgz#261b8f518301f1d834e36342b9fea095d2620a26" dependencies: @@ -2699,10 +2712,14 @@ core-js@^1.0.0: version "1.2.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" -core-js@^2.2.0, core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.1: +core-js@^2.2.0, core-js@^2.5.0: version "2.5.3" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e" +core-js@^2.4.0, core-js@^2.5.1: + version "2.5.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" + core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -4772,8 +4789,8 @@ focus-trap-react@^3.0.4, focus-trap-react@^3.1.1: focus-trap "^2.0.1" focus-trap@^2.0.1: - version "2.4.3" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.4.3.tgz#95edc23e77829b7772cb2486d61fd6371ce112f9" + version "2.4.5" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.4.5.tgz#91c9c9ffb907f8f4446d80202dda9c12c2853ddb" dependencies: tabbable "^1.0.3" @@ -5905,7 +5922,7 @@ icalendar@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/icalendar/-/icalendar-0.7.1.tgz#d0d3486795f8f1c5cf4f8cafac081b4b4e7a32ae" -iconv-lite@0.4, iconv-lite@0.4.19, iconv-lite@^0.4.13, iconv-lite@^0.4.17, iconv-lite@^0.4.19, iconv-lite@~0.4.13: +iconv-lite@0.4, iconv-lite@0.4.19, iconv-lite@^0.4.13, iconv-lite@^0.4.17, iconv-lite@^0.4.19: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" @@ -5917,6 +5934,12 @@ iconv-lite@0.4.7: version "0.4.7" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.7.tgz#89d32fec821bf8597f44609b4bc09bed5c209a23" +iconv-lite@~0.4.13: + version "0.4.23" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" + dependencies: + safer-buffer ">= 2.1.2 < 3" + icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" @@ -10041,7 +10064,16 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-ace@^5.5.0, react-ace@^5.9.0: +react-ace@^5.5.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.10.0.tgz#e328b37ac52759f700be5afdb86ada2f5ec84c5e" + dependencies: + brace "^0.11.0" + lodash.get "^4.4.2" + lodash.isequal "^4.1.1" + prop-types "^15.5.8" + +react-ace@^5.9.0: version "5.9.0" resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.9.0.tgz#427a1cc4869b960a6f9748aa7eb169a9269fc336" dependencies: @@ -10129,6 +10161,10 @@ react-input-range@^1.3.0: autobind-decorator "^1.3.4" prop-types "^15.5.8" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + react-markdown-renderer@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/react-markdown-renderer/-/react-markdown-renderer-1.4.0.tgz#f3b95bd9fc7f7bf8ab3f0150aa696b41740e7d01" @@ -10271,14 +10307,15 @@ react-toggle@4.0.2: classnames "^2.2.5" react-virtualized@^9.18.5: - version "9.18.5" - resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.18.5.tgz#42dd390ebaa7ea809bfcaf775d39872641679b89" + version "9.19.1" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.19.1.tgz#84b53253df2d9df61c85ce037141edccc70a73fd" dependencies: babel-runtime "^6.26.0" classnames "^2.2.3" dom-helpers "^2.4.0 || ^3.0.0" loose-envify "^1.3.0" prop-types "^15.6.0" + react-lifecycles-compat "^3.0.4" react-vis@^1.8.1: version "1.9.2" @@ -10381,7 +10418,7 @@ read-pkg@^2.0.0: normalize-package-data "^2.3.2" path-type "^2.0.0" -readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@~2.3.3: +readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@~2.3.3: version "2.3.5" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.5.tgz#b4f85003a938cbb6ecbce2a124fb1012bd1a838d" dependencies: @@ -10393,6 +10430,18 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable string_decoder "~1.0.3" util-deprecate "~1.0.1" +readable-stream@^2.2.2: + version "2.3.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@~1.0.2: version "1.0.34" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" @@ -10928,10 +10977,14 @@ rxjs@5.4.3: dependencies: symbol-observable "^1.0.1" -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1: +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + safe-json-stringify@~1: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.1.0.tgz#bd2b6dad1ebafab3c24672a395527f01804b7e19" @@ -10949,6 +11002,10 @@ safefs@^4.0.0: editions "^1.1.1" graceful-fs "^4.1.4" +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + samsam@1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" @@ -11622,6 +11679,12 @@ string_decoder@~1.0.3: dependencies: safe-buffer "~5.1.0" +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + dependencies: + safe-buffer "~5.1.0" + stringstream@~0.0.4, stringstream@~0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" @@ -11820,8 +11883,8 @@ tabbable@1.1.0: resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.0.tgz#2c9a9c9f09db5bb0659f587d532548dd6ef2067b" tabbable@^1.0.3, tabbable@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.2.tgz#b171680aea6e0a3e9281ff23532e2e5de11c0d94" + version "1.1.3" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.3.tgz#0e4ee376f3631e42d7977a074dbd2b3827843081" table@^3.7.8: version "3.8.3" @@ -12276,8 +12339,8 @@ typescript@^2.8.1: resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.8.1.tgz#6160e4f8f195d5ba81d4876f9c0cc1fbc0820624" ua-parser-js@^0.7.9: - version "0.7.17" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac" + version "0.7.18" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed" uc.micro@^1.0.1, uc.micro@^1.0.3: version "1.0.5" @@ -13116,7 +13179,11 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: dependencies: iconv-lite "0.4.19" -whatwg-fetch@>=0.10.0, whatwg-fetch@^2.0.3: +whatwg-fetch@>=0.10.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" + +whatwg-fetch@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84" From 60d5917e40208a9a4a81bcac3d92a562cf237d4a Mon Sep 17 00:00:00 2001 From: kobelb Date: Thu, 7 Jun 2018 15:00:27 -0400 Subject: [PATCH 037/183] Removing unused code, fixing misspelling, adding comment --- .../lib/privileges/privilege_action_registry.test.js | 2 +- x-pack/plugins/security/server/routes/api/v1/privileges.js | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js index 2fcfd5c2caaa00..398a68a097ed94 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js @@ -162,7 +162,7 @@ registerPrivilegesWithClusterTest(`updates privileges when nested privileges arr } }); -registerPrivilegesWithClusterTest(`updates privileges when nested propertry array values are reordered`, { +registerPrivilegesWithClusterTest(`updates privileges when nested property array values are reordered`, { expectedPrivileges: { kibana: { foo: ['one', 'two'] diff --git a/x-pack/plugins/security/server/routes/api/v1/privileges.js b/x-pack/plugins/security/server/routes/api/v1/privileges.js index 1e6ac21fb54e06..f7d6e7c6a00394 100644 --- a/x-pack/plugins/security/server/routes/api/v1/privileges.js +++ b/x-pack/plugins/security/server/routes/api/v1/privileges.js @@ -7,12 +7,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. */ -import { getClient } from '../../../../../../server/lib/get_client_shield'; import { buildPrivilegeMap } from '../../../lib/privileges/privileges'; export function initPrivilegesApi(server) { - const callWithInternalUser = getClient(server).callWithInternalUser; // eslint-disable-line no-unused-vars - const config = server.config(); const kibanaVersion = config.get('pkg.version'); const application = config.get('xpack.security.rbac.application'); @@ -21,6 +18,10 @@ export function initPrivilegesApi(server) { method: 'GET', path: '/api/security/v1/privileges', handler(request, reply) { + // we're returing our representation of the privileges, as opposed to the ones that are stored + // in Elasticsearch because our current thinking is that we'll associate additional structure/metadata + // with our view of them to allow users to more efficiently edit privileges for roles, and serialize it + // into a different structure for enforcement within Elasticsearch const privileges = buildPrivilegeMap(application, kibanaVersion); reply(Object.values(privileges[application])); } From 4b3c6bae3e5aee41d407b1f2ebf615461493bfd4 Mon Sep 17 00:00:00 2001 From: kobelb Date: Thu, 7 Jun 2018 15:02:36 -0400 Subject: [PATCH 038/183] Putting a file back --- .../routes/api/v1/{roles/index.js => roles.js} | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) rename x-pack/plugins/security/server/routes/api/v1/{roles/index.js => roles.js} (84%) diff --git a/x-pack/plugins/security/server/routes/api/v1/roles/index.js b/x-pack/plugins/security/server/routes/api/v1/roles.js similarity index 84% rename from x-pack/plugins/security/server/routes/api/v1/roles/index.js rename to x-pack/plugins/security/server/routes/api/v1/roles.js index 8e0e114798c3c7..180160cb85a17e 100644 --- a/x-pack/plugins/security/server/routes/api/v1/roles/index.js +++ b/x-pack/plugins/security/server/routes/api/v1/roles.js @@ -6,10 +6,10 @@ import _ from 'lodash'; import Boom from 'boom'; -import { getClient } from '../../../../../../../server/lib/get_client_shield'; -import { roleSchema } from '../../../../lib/role_schema'; -import { wrapError } from '../../../../lib/errors'; -import { routePreCheckLicense } from '../../../../lib/route_pre_check_license'; +import { getClient } from '../../../../../../server/lib/get_client_shield'; +import { roleSchema } from '../../../lib/role_schema'; +import { wrapError } from '../../../lib/errors'; +import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; export function initRolesApi(server) { const callWithRequest = getClient(server).callWithRequest; @@ -21,9 +21,7 @@ export function initRolesApi(server) { handler(request, reply) { return callWithRequest(request, 'shield.getRole').then( (response) => { - const roles = _.map(response, (role, name) => { - return _.assign(role, { name }); - }); + const roles = _.map(response, (role, name) => _.assign(role, { name })); return reply(roles); }, @@ -58,7 +56,6 @@ export function initRolesApi(server) { handler(request, reply) { const name = request.params.name; const body = _.omit(request.payload, 'name'); - return callWithRequest(request, 'shield.putRole', { name, body }).then( () => reply(request.payload), _.flow(wrapError, reply)); From 117b0d4262c946f82b35a8286fb86173e52255f9 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Mon, 11 Jun 2018 11:42:55 -0400 Subject: [PATCH 039/183] No longer creating the roles on start-up (#19799) --- x-pack/plugins/security/index.js | 3 - .../create_default_roles.test.js.snap | 9 - .../lib/authorization/create_default_roles.js | 63 ----- .../create_default_roles.test.js | 216 ------------------ .../apis/saved_objects/index.js | 30 +++ 5 files changed, 30 insertions(+), 291 deletions(-) delete mode 100644 x-pack/plugins/security/server/lib/authorization/__snapshots__/create_default_roles.test.js.snap delete mode 100644 x-pack/plugins/security/server/lib/authorization/create_default_roles.js delete mode 100644 x-pack/plugins/security/server/lib/authorization/create_default_roles.test.js diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 70596f89e4039b..e0db6b206e3d91 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -18,7 +18,6 @@ import { checkLicense } from './server/lib/check_license'; import { initAuthenticator } from './server/lib/authentication/authenticator'; import { mirrorStatusAndInitialize } from './server/lib/mirror_status_and_initialize'; import { registerPrivilegesWithCluster } from './server/lib/privileges'; -import { createDefaultRoles } from './server/lib/authorization/create_default_roles'; import { initPrivilegesApi } from './server/routes/api/v1/privileges'; import { hasPrivilegesWithServer } from './server/lib/authorization/has_privileges'; import { SecurityAuditLogger } from './server/lib/audit_logger'; @@ -46,7 +45,6 @@ export const security = (kibana) => new kibana.Plugin({ }).default(), rbac: Joi.object({ enabled: Joi.boolean().default(false), - createDefaultRoles: Joi.boolean().default(true), application: Joi.string().default('kibana').regex( /[a-zA-Z0-9-_]+/, `may contain alphanumeric characters (a-z, A-Z, 0-9), underscores and hyphens` @@ -99,7 +97,6 @@ export const security = (kibana) => new kibana.Plugin({ } await registerPrivilegesWithCluster(server); - await createDefaultRoles(server); }); // Register a function that is called whenever the xpack info changes, diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/create_default_roles.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/create_default_roles.test.js.snap deleted file mode 100644 index 20c6d549ffb22d..00000000000000 --- a/x-pack/plugins/security/server/lib/authorization/__snapshots__/create_default_roles.test.js.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`dashboard_only_user throws error when shield.getRole throws non 404 error 1`] = `undefined`; - -exports[`dashboard_only_user throws error when shield.putRole throws error 1`] = `"Some other error"`; - -exports[`rbac_user throws error when shield.getRole throws non 404 error 1`] = `undefined`; - -exports[`rbac_user throws error when shield.putRole throws error 1`] = `"Some other error"`; diff --git a/x-pack/plugins/security/server/lib/authorization/create_default_roles.js b/x-pack/plugins/security/server/lib/authorization/create_default_roles.js deleted file mode 100644 index 933c36a98cf7ab..00000000000000 --- a/x-pack/plugins/security/server/lib/authorization/create_default_roles.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. - */ - -/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { DEFAULT_RESOURCE } from '../../../common/constants'; - - -const createRoleIfDoesntExist = async (callCluster, { name, application, privilege }) => { - try { - await callCluster('shield.getRole', { name }); - } catch (err) { - if (err.statusCode !== 404) { - throw err; - } - - await callCluster('shield.putRole', { - name, - body: { - cluster: [], - index: [], - applications: [ - { - application, - privileges: [ privilege ], - resources: [ DEFAULT_RESOURCE ] - } - ] - } - }); - } -}; - -export async function createDefaultRoles(server) { - const config = server.config(); - - if (!config.get('xpack.security.rbac.createDefaultRoles')) { - return; - } - - const application = config.get('xpack.security.rbac.application'); - - const callCluster = getClient(server).callWithInternalUser; - - const createKibanaUserRole = createRoleIfDoesntExist(callCluster, { - name: `${application}_rbac_user`, - application, - privilege: 'all' - }); - - const createKibanaDashboardOnlyRole = createRoleIfDoesntExist(callCluster, { - name: `${application}_rbac_dashboard_only_user`, - application, - privilege: 'read' - }); - - await Promise.all([createKibanaUserRole, createKibanaDashboardOnlyRole]); -} diff --git a/x-pack/plugins/security/server/lib/authorization/create_default_roles.test.js b/x-pack/plugins/security/server/lib/authorization/create_default_roles.test.js deleted file mode 100644 index fba00600397d96..00000000000000 --- a/x-pack/plugins/security/server/lib/authorization/create_default_roles.test.js +++ /dev/null @@ -1,216 +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 { createDefaultRoles } from './create_default_roles'; -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { DEFAULT_RESOURCE } from '../../../common/constants'; - -jest.mock('../../../../../server/lib/get_client_shield', () => ({ - getClient: jest.fn() -})); - -const mockShieldClient = () => { - const mockCallWithInternalUser = jest.fn(); - getClient.mockReturnValue({ - callWithInternalUser: mockCallWithInternalUser - }); - - return { - mockCallWithInternalUser - }; -}; - -const defaultApplication = 'foo-application'; - -const createMockServer = ({ settings = {} } = {}) => { - const mockServer = { - config: jest.fn().mockReturnValue({ - get: jest.fn() - }) - }; - - const defaultSettings = { - 'xpack.security.rbac.createDefaultRoles': true, - 'xpack.security.rbac.application': defaultApplication - }; - - mockServer.config().get.mockImplementation(key => { - return key in settings ? settings[key] : defaultSettings[key]; - }); - - return mockServer; -}; - -test(`doesn't create roles if createDefaultRoles is false`, async () => { - const { mockCallWithInternalUser } = mockShieldClient(); - const mockServer = createMockServer({ - settings: { - 'xpack.security.rbac.createDefaultRoles': false - } - }); - - await createDefaultRoles(mockServer); - - expect(mockCallWithInternalUser).toHaveBeenCalledTimes(0); -}); - -describe(`rbac_user`, () => { - test(`doesn't create \${application}_rbac_user when it exists`, async () => { - const { mockCallWithInternalUser } = mockShieldClient(); - const mockServer = createMockServer(); - mockCallWithInternalUser.mockReturnValue(null); - - await createDefaultRoles(mockServer); - - expect(mockCallWithInternalUser).not.toHaveBeenCalledWith('shield.putRole', expect.anything()); - }); - - test(`creates \${application}_rbac_user when it doesn't exist`, async () => { - const { mockCallWithInternalUser } = mockShieldClient(); - const mockServer = createMockServer(); - mockCallWithInternalUser.mockImplementation(async (endpoint, params) => { - if (endpoint === 'shield.getRole' && params.name === `${defaultApplication}_rbac_user`) { - throw { - statusCode: 404 - }; - } - - return null; - }); - - await createDefaultRoles(mockServer); - - expect(mockCallWithInternalUser).toHaveBeenCalledWith('shield.putRole', { - name: `${defaultApplication}_rbac_user`, - body: { - cluster: [], - index: [], - applications: [ - { - application: defaultApplication, - privileges: [ 'all' ], - resources: [ DEFAULT_RESOURCE ] - } - ] - } - }); - }); - - test(`throws error when shield.getRole throws non 404 error`, async () => { - const { mockCallWithInternalUser } = mockShieldClient(); - const mockServer = createMockServer(); - mockCallWithInternalUser.mockImplementation(async (endpoint, params) => { - if (endpoint === 'shield.getRole' && params.name === `${defaultApplication}_rbac_user`) { - throw { - statusCode: 500 - }; - } - - return null; - }); - - expect(createDefaultRoles(mockServer)).rejects.toThrowErrorMatchingSnapshot(); - }); - - test(`throws error when shield.putRole throws error`, async () => { - const { mockCallWithInternalUser } = mockShieldClient(); - const mockServer = createMockServer(); - mockCallWithInternalUser.mockImplementation(async (endpoint, params) => { - if (endpoint === 'shield.getRole' && params.name === `${defaultApplication}_rbac_user`) { - throw { - statusCode: 404 - }; - } - - if (endpoint === 'shield.putRole' && params.name === `${defaultApplication}_rbac_user`) { - throw new Error('Some other error'); - } - - return null; - }); - - await expect(createDefaultRoles(mockServer)).rejects.toThrowErrorMatchingSnapshot(); - }); -}); - -describe(`dashboard_only_user`, () => { - test(`doesn't create \${application}_rbac_dashboard_only_user when it exists`, async () => { - const { mockCallWithInternalUser } = mockShieldClient(); - const mockServer = createMockServer(); - mockCallWithInternalUser.mockReturnValue(null); - - await createDefaultRoles(mockServer); - - expect(mockCallWithInternalUser).not.toHaveBeenCalledWith('shield.putRole', expect.anything()); - }); - - test(`creates \${application}_rbac_dashboard_only_user when it doesn't exist`, async () => { - const { mockCallWithInternalUser } = mockShieldClient(); - const mockServer = createMockServer(); - mockCallWithInternalUser.mockImplementation(async (endpoint, params) => { - if (endpoint === 'shield.getRole' && params.name === `${defaultApplication}_rbac_dashboard_only_user`) { - throw { - statusCode: 404 - }; - } - - return null; - }); - - await createDefaultRoles(mockServer); - - expect(mockCallWithInternalUser).toHaveBeenCalledWith('shield.putRole', { - name: `${defaultApplication}_rbac_dashboard_only_user`, - body: { - cluster: [], - index: [], - applications: [ - { - application: defaultApplication, - privileges: [ 'read' ], - resources: [ DEFAULT_RESOURCE ] - } - ] - } - }); - }); - - test(`throws error when shield.getRole throws non 404 error`, async () => { - const { mockCallWithInternalUser } = mockShieldClient(); - const mockServer = createMockServer(); - mockCallWithInternalUser.mockImplementation(async (endpoint, params) => { - if (endpoint === 'shield.getRole' && params.name === `${defaultApplication}_rbac_dashboard_only_user`) { - throw { - statusCode: 500 - }; - } - - return null; - }); - - await expect(createDefaultRoles(mockServer)).rejects.toThrowErrorMatchingSnapshot(); - }); - - test(`throws error when shield.putRole throws error`, async () => { - const { mockCallWithInternalUser } = mockShieldClient(); - const mockServer = createMockServer(); - mockCallWithInternalUser.mockImplementation(async (endpoint, params) => { - if (endpoint === 'shield.getRole' && params.name === `${defaultApplication}_rbac_dashboard_only_user`) { - throw { - statusCode: 404 - }; - } - - if (endpoint === 'shield.putRole' && params.name === `${defaultApplication}_rbac_dashboard_only_user`) { - throw new Error('Some other error'); - } - - return null; - }); - - await expect(createDefaultRoles(mockServer)).rejects.toThrowErrorMatchingSnapshot(); - }); -}); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/index.js b/x-pack/test/rbac_api_integration/apis/saved_objects/index.js index 644bf23220648e..1199bd5986e66d 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/index.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/index.js @@ -11,6 +11,36 @@ export default function ({ loadTestFile, getService }) { describe('saved_objects', () => { before(async () => { + await es.shield.putRole({ + name: 'kibana_rbac_user', + body: { + cluster: [], + index: [], + applications: [ + { + application: 'kibana', + privileges: [ 'all' ], + resources: [ 'default' ] + } + ] + } + }); + + await es.shield.putRole({ + name: 'kibana_rbac_dashboard_only_user', + body: { + cluster: [], + index: [], + applications: [ + { + application: 'kibana', + privileges: [ 'read' ], + resources: [ 'default' ] + } + ] + } + }); + await es.shield.putUser({ username: AUTHENTICATION.NOT_A_KIBANA_USER.USERNAME, body: { From c42635bf34c271e9787ac59d96c0d8fbd50ee023 Mon Sep 17 00:00:00 2001 From: kobelb Date: Tue, 12 Jun 2018 14:50:19 -0400 Subject: [PATCH 040/183] Removing kibana_rbac_dashboard_only_user from dashboard only role defaults --- x-pack/plugins/dashboard_mode/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/dashboard_mode/index.js b/x-pack/plugins/dashboard_mode/index.js index 4333aa772c779b..e4afe029fc01ad 100644 --- a/x-pack/plugins/dashboard_mode/index.js +++ b/x-pack/plugins/dashboard_mode/index.js @@ -29,7 +29,7 @@ export function dashboardMode(kibana) { [CONFIG_DASHBOARD_ONLY_MODE_ROLES]: { name: 'Dashboards only roles', description: `Roles that belong to View Dashboards Only mode`, - value: ['kibana_dashboard_only_user', 'kibana_rbac_dashboard_only_user'], + value: ['kibana_dashboard_only_user'], category: ['dashboard'], } }, From e88707679bbbdac100f52c98d7654f18c7bda48b Mon Sep 17 00:00:00 2001 From: kobelb Date: Tue, 12 Jun 2018 15:07:27 -0400 Subject: [PATCH 041/183] Fixing small issue with editing Kibana privileges --- x-pack/plugins/security/public/views/management/edit_role.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security/public/views/management/edit_role.js b/x-pack/plugins/security/public/views/management/edit_role.js index ffc1962f449378..77316db93bbd26 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.js +++ b/x-pack/plugins/security/public/views/management/edit_role.js @@ -36,7 +36,7 @@ const getKibanaPrivileges = (kibanaApplicationPrivilege, role, application) => { // we're filtering out privileges for non-default resources as well incase // the roles were created in a future version const applications = role.applications - .filter(x => x.application === application && x.application.resources.all(r => r === DEFAULT_RESOURCE)); + .filter(x => x.application === application && x.resources.every(r => r === DEFAULT_RESOURCE)); const assigned = _.uniq(_.flatten(_.pluck(applications, 'privileges'))); assigned.forEach(a => { From a76e4a51b9749df33d8f880bddc536a01cf1a676 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 13 Jun 2018 15:22:13 -0400 Subject: [PATCH 042/183] [RBAC Phase 1] - Update application privileges when XPack license changes (#19839) * Adding start to supporting basic license and switching to plat/gold * Initialize application privilages on XPack license change * restore mirror_status_and_initialize * additional tests and peer review updates * Introducing watchStatusAndLicenseToInitialize * Adding some tests * One more test * Even better tests * Removing unused mirrorStatusAndInitialize * Throwing an error if the wrong status function is called --- x-pack/plugins/security/index.js | 45 ++-- .../security/server/lib/check_license.js | 5 +- .../lib/mirror_status_and_initialize.js | 63 ----- .../lib/mirror_status_and_initialize.test.js | 138 ----------- .../watch_status_and_license_to_initialize.js | 55 +++++ ...h_status_and_license_to_initialize.test.js | 223 ++++++++++++++++++ .../server/lib/__tests__/xpack_info.js | 76 +++++- .../xpack_main/server/lib/xpack_info.js | 51 +++- 8 files changed, 429 insertions(+), 227 deletions(-) delete mode 100644 x-pack/plugins/security/server/lib/mirror_status_and_initialize.js delete mode 100644 x-pack/plugins/security/server/lib/mirror_status_and_initialize.test.js create mode 100644 x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js create mode 100644 x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index e0db6b206e3d91..0c60bd282b9e4d 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -16,13 +16,13 @@ import { validateConfig } from './server/lib/validate_config'; import { authenticateFactory } from './server/lib/auth_redirect'; import { checkLicense } from './server/lib/check_license'; import { initAuthenticator } from './server/lib/authentication/authenticator'; -import { mirrorStatusAndInitialize } from './server/lib/mirror_status_and_initialize'; -import { registerPrivilegesWithCluster } from './server/lib/privileges'; import { initPrivilegesApi } from './server/routes/api/v1/privileges'; import { hasPrivilegesWithServer } from './server/lib/authorization/has_privileges'; import { SecurityAuditLogger } from './server/lib/audit_logger'; import { AuditLogger } from '../../server/lib/audit_logger'; import { SecureSavedObjectsClient } from './server/lib/saved_objects_client/secure_saved_objects_client'; +import { registerPrivilegesWithCluster } from './server/lib/privileges'; +import { watchStatusAndLicenseToInitialize } from './server/lib/watch_status_and_license_to_initialize'; export const security = (kibana) => new kibana.Plugin({ id: 'security', @@ -88,20 +88,23 @@ export const security = (kibana) => new kibana.Plugin({ }, async init(server) { + const plugin = this; + const config = server.config(); const xpackMainPlugin = server.plugins.xpack_main; + const xpackInfo = xpackMainPlugin.info; - mirrorStatusAndInitialize(xpackMainPlugin.status, this.status, async () => { - if (!config.get('xpack.security.rbac.enabled')) { - return; - } - - await registerPrivilegesWithCluster(server); - }); + 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 - xpackMainPlugin.info.feature(this.id).registerLicenseCheckResultsGenerator(checkLicense); + xpackInfoFeature.registerLicenseCheckResultsGenerator(checkLicense); + + watchStatusAndLicenseToInitialize(xpackMainPlugin, plugin, async (license) => { + if (license.allowRbac) { + await registerPrivilegesWithCluster(server); + } + }); validateConfig(config, message => server.log(['security', 'warning'], message)); @@ -123,9 +126,23 @@ export const security = (kibana) => new kibana.Plugin({ mappings, onBeforeWrite }) => { - const hasPrivileges = hasPrivilegesWithRequest(request); - const adminCluster = server.plugins.elasticsearch.getCluster('admin'); + + if (!xpackInfoFeature.getLicenseCheckResults().allowRbac) { + const { callWithRequest } = adminCluster; + const callCluster = (...args) => callWithRequest(request, ...args); + + const repository = new savedObjects.SavedObjectsRepository({ + index, + mappings, + onBeforeWrite, + callCluster, + }); + + return new savedObjects.SavedObjectsClient(repository); + } + + const hasPrivileges = hasPrivilegesWithRequest(request); const { callWithInternalUser } = adminCluster; const repository = new savedObjects.SavedObjectsRepository({ @@ -156,9 +173,7 @@ export const security = (kibana) => new kibana.Plugin({ initLogoutView(server); server.injectUiAppVars('login', () => { - const pluginId = 'security'; - const xpackInfo = server.plugins.xpack_main.info; - const { showLogin, loginMessage, allowLogin } = xpackInfo.feature(pluginId).getLicenseCheckResults() || {}; + const { showLogin, loginMessage, allowLogin } = xpackInfo.feature(plugin.id).getLicenseCheckResults() || {}; return { loginState: { diff --git a/x-pack/plugins/security/server/lib/check_license.js b/x-pack/plugins/security/server/lib/check_license.js index 624f80e64be79d..b1f26ced352ad8 100644 --- a/x-pack/plugins/security/server/lib/check_license.js +++ b/x-pack/plugins/security/server/lib/check_license.js @@ -33,6 +33,7 @@ export function checkLicense(xPackInfo) { showLinks: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, + allowRbac: false, loginMessage: 'Login is currently disabled. Administrators should consult the Kibana logs for more details.' }; } @@ -46,6 +47,7 @@ export function checkLicense(xPackInfo) { showLinks: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, + allowRbac: false, linksMessage: isLicenseBasic ? 'Your Basic license does not support Security. Please upgrade your license.' : 'Access is denied because Security is disabled in Elasticsearch.' @@ -60,6 +62,7 @@ export function checkLicense(xPackInfo) { showLinks: true, // Only platinum and trial licenses are compliant with field- and document-level security. allowRoleDocumentLevelSecurity: isLicensePlatinumOrTrial, - allowRoleFieldLevelSecurity: isLicensePlatinumOrTrial + allowRoleFieldLevelSecurity: isLicensePlatinumOrTrial, + allowRbac: true, }; } diff --git a/x-pack/plugins/security/server/lib/mirror_status_and_initialize.js b/x-pack/plugins/security/server/lib/mirror_status_and_initialize.js deleted file mode 100644 index af9ad91db70fb4..00000000000000 --- a/x-pack/plugins/security/server/lib/mirror_status_and_initialize.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. - */ - -/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { Observable } from 'rxjs'; - -export function mirrorStatusAndInitialize(upstreamStatus, downstreamStatus, onGreen) { - const currentState$ = Observable - .of({ - state: upstreamStatus.state, - message: upstreamStatus.message, - }); - - const newState$ = Observable - .fromEvent(upstreamStatus, 'change', null, (previousState, previousMsg, state, message) => { - return { - state, - message, - }; - }); - - const state$ = Observable.merge(currentState$, newState$); - - let onGreenPromise; - const onGreen$ = Observable.create(observer => { - if (!onGreenPromise) { - onGreenPromise = onGreen(); - } - - onGreenPromise - .then(() => { - observer.next({ - state: 'green', - message: 'Ready', - }); - }) - .catch((err) => { - onGreenPromise = null; - observer.next({ - state: 'red', - message: err.message - }); - }); - }); - - - state$ - .switchMap(({ state, message }) => { - if (state !== 'green') { - return Observable.of({ state, message }); - } - - return onGreen$; - }) - .do(({ state, message }) => { - downstreamStatus[state](message); - }) - .subscribe(); -} diff --git a/x-pack/plugins/security/server/lib/mirror_status_and_initialize.test.js b/x-pack/plugins/security/server/lib/mirror_status_and_initialize.test.js deleted file mode 100644 index 7c1cb525761306..00000000000000 --- a/x-pack/plugins/security/server/lib/mirror_status_and_initialize.test.js +++ /dev/null @@ -1,138 +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. - */ - -/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { EventEmitter } from 'events'; -import { once } from 'lodash'; -import { mirrorStatusAndInitialize } from './mirror_status_and_initialize'; - -['red', 'yellow', 'disabled' ].forEach(state => { - test(`mirrors ${state} immediately`, () => { - const message = `${state} is the status`; - const upstreamStatus = new EventEmitter(); - upstreamStatus.state = state; - upstreamStatus.message = message; - - const downstreamStatus = { - [state]: jest.fn() - }; - - mirrorStatusAndInitialize(upstreamStatus, downstreamStatus); - expect(downstreamStatus[state]).toHaveBeenCalledTimes(1); - expect(downstreamStatus[state]).toHaveBeenCalledWith(message); - }); -}); - -test(`calls onGreen and doesn't immediately set downstream status when the initial status is green`, () => { - const upstreamStatus = new EventEmitter(); - upstreamStatus.state = 'green'; - upstreamStatus.message = ''; - - const downstreamStatus = { - green: jest.fn() - }; - - const onGreenMock = jest.fn().mockImplementation(() => new Promise(() => {})); - mirrorStatusAndInitialize(upstreamStatus, downstreamStatus, onGreenMock); - expect(onGreenMock).toHaveBeenCalledTimes(1); - expect(downstreamStatus.green).toHaveBeenCalledTimes(0); -}); - -test(`only calls onGreen once if it resolves immediately`, () => { - const upstreamStatus = new EventEmitter(); - upstreamStatus.state = 'green'; - upstreamStatus.message = ''; - - const downstreamStatus = { - green: () => {} - }; - - const onGreenMock = jest.fn().mockImplementation(() => Promise.resolve()); - - mirrorStatusAndInitialize(upstreamStatus, downstreamStatus, onGreenMock); - upstreamStatus.emit('change', '', '', 'green', ''); - expect(onGreenMock).toHaveBeenCalledTimes(1); -}); - -test(`calls onGreen twice if it rejects`, (done) => { - const upstreamStatus = new EventEmitter(); - upstreamStatus.state = 'green'; - upstreamStatus.message = ''; - - const downstreamStatus = { - red: once(() => { - // once we see this red, we immediately trigger the upstream status again - // to have it retrigger the onGreen function - upstreamStatus.emit('change', '', '', 'green', ''); - }), - }; - - let count = 0; - const onGreenMock = jest.fn().mockImplementation(() => { - if (++count === 2) { - done(); - } - - return Promise.reject(new Error()); - }); - - mirrorStatusAndInitialize(upstreamStatus, downstreamStatus, onGreenMock); -}); - -test(`sets downstream status to green when onGreen promise resolves`, (done) => { - const state = 'green'; - const message = `${state} is the status`; - const upstreamStatus = new EventEmitter(); - upstreamStatus.state = state; - upstreamStatus.message = message; - - const downstreamStatus = { - green: () => { - done(); - } - }; - - const onGreenMock = jest.fn().mockImplementation(() => Promise.resolve()); - mirrorStatusAndInitialize(upstreamStatus, downstreamStatus, onGreenMock); -}); - -test(`sets downstream status to red when onGreen promise rejects`, (done) => { - const upstreamStatus = new EventEmitter(); - upstreamStatus.state = 'green'; - upstreamStatus.message = ''; - - const errorMessage = 'something went real wrong'; - - const downstreamStatus = { - red: (msg) => { - expect(msg).toBe(errorMessage); - done(); - } - }; - - const onGreenMock = jest.fn().mockImplementation(() => Promise.reject(new Error(errorMessage))); - mirrorStatusAndInitialize(upstreamStatus, downstreamStatus, onGreenMock); -}); - -['red', 'yellow', 'disabled' ].forEach(state => { - test(`switches from uninitialized to ${state} on event`, () => { - const message = `${state} is the status`; - const upstreamStatus = new EventEmitter(); - upstreamStatus.state = 'uninitialized'; - upstreamStatus.message = 'uninitialized'; - - const downstreamStatus = { - uninitialized: jest.fn(), - [state]: jest.fn(), - }; - - mirrorStatusAndInitialize(upstreamStatus, downstreamStatus); - upstreamStatus.emit('change', '', '', state, message); - expect(downstreamStatus[state]).toHaveBeenCalledTimes(1); - expect(downstreamStatus[state]).toHaveBeenCalledWith(message); - }); -}); diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js new file mode 100644 index 00000000000000..6e68a4e404705c --- /dev/null +++ b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { Observable } from 'rxjs'; + +export function watchStatusAndLicenseToInitialize(xpackMainPlugin, downstreamPlugin, initialize) { + const xpackInfo = xpackMainPlugin.info; + const xpackInfoFeature = xpackInfo.feature(downstreamPlugin.id); + + const upstreamStatus = xpackMainPlugin.status; + const currentStatus$ = Observable + .of({ + state: upstreamStatus.state, + message: upstreamStatus.message, + }); + const newStatus$ = Observable + .fromEvent(upstreamStatus, 'change', null, (previousState, previousMsg, state, message) => { + return { + state, + message, + }; + }); + const status$ = Observable.merge(currentStatus$, newStatus$); + + const currentLicense$ = Observable + .of(xpackInfoFeature.getLicenseCheckResults()); + const newLicense$ = Observable + .fromEventPattern(xpackInfoFeature.registerLicenseChangeCallback) + .map(() => xpackInfoFeature.getLicenseCheckResults()); + const license$ = Observable.merge(currentLicense$, newLicense$); + + Observable.combineLatest(status$, license$) + .map(([status, license]) => ({ status, license })) + .switchMap(({ status, license }) => { + if (status.state !== 'green') { + return Observable.of({ state: status.state, message: status.message }); + } + + return initialize(license) + .then(() => ({ + state: 'green', + message: 'Ready', + })) + .catch((err) => ({ + state: 'red', + message: err.message + })); + }) + .do(({ state, message }) => { + downstreamPlugin.status[state](message); + }) + .subscribe(); +} diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js new file mode 100644 index 00000000000000..3826edf6767617 --- /dev/null +++ b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js @@ -0,0 +1,223 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { EventEmitter } from 'events'; +import { watchStatusAndLicenseToInitialize } from './watch_status_and_license_to_initialize'; + +const createMockXpackMainPluginAndFeature = (featureId) => { + const licenseChangeCallbacks = []; + + const mockFeature = { + getLicenseCheckResults: jest.fn(), + registerLicenseChangeCallback: (callback) => { + licenseChangeCallbacks.push(callback); + }, + mock: { + triggerLicenseChange: () => { + for (const callback of licenseChangeCallbacks) { + callback(); + } + }, + setLicenseCheckResults: (value) => { + mockFeature.getLicenseCheckResults.mockReturnValue(value); + } + } + }; + + const mockXpackMainPlugin = { + info: { + feature: (id) => { + if (id === featureId) { + return mockFeature; + } + throw new Error('Unexpected feature'); + } + }, + status: new EventEmitter(), + mock: { + setStatus: (state, message) => { + mockXpackMainPlugin.status.state = state; + mockXpackMainPlugin.status.message = message; + mockXpackMainPlugin.status.emit('change', null, null, state, message); + } + } + }; + + return { mockXpackMainPlugin, mockFeature }; +}; + +const createMockDownstreamPlugin = (id) => { + const defaultImplementation = () => { throw new Error('Not implemented'); }; + return { + id, + status: { + disabled: jest.fn().mockImplementation(defaultImplementation), + yellow: jest.fn().mockImplementation(defaultImplementation), + green: jest.fn().mockImplementation(defaultImplementation), + red: jest.fn().mockImplementation(defaultImplementation), + }, + }; +}; + +['red', 'yellow', 'disabled' ].forEach(state => { + test(`mirrors ${state} immediately`, () => { + const pluginId = 'foo-plugin'; + const message = `${state} is now the state`; + const { mockXpackMainPlugin } = createMockXpackMainPluginAndFeature(pluginId); + mockXpackMainPlugin.mock.setStatus(state, message); + const downstreamPlugin = createMockDownstreamPlugin(pluginId); + const initializeMock = jest.fn(); + downstreamPlugin.status[state].mockImplementation(() => {}); + + watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); + + expect(initializeMock).not.toHaveBeenCalled(); + expect(downstreamPlugin.status[state]).toHaveBeenCalledTimes(1); + expect(downstreamPlugin.status[state]).toHaveBeenCalledWith(message); + }); +}); + +test(`calls initialize and doesn't immediately set downstream status when the initial status is green`, () => { + const pluginId = 'foo-plugin'; + const { mockXpackMainPlugin, mockFeature } = createMockXpackMainPluginAndFeature(pluginId); + mockXpackMainPlugin.mock.setStatus('green', 'green is now the state'); + const licenseCheckResults = Symbol(); + mockFeature.mock.setLicenseCheckResults(licenseCheckResults); + const downstreamPlugin = createMockDownstreamPlugin(pluginId); + const initializeMock = jest.fn().mockImplementation(() => new Promise(() => {})); + + watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledWith(licenseCheckResults); + expect(downstreamPlugin.status.green).toHaveBeenCalledTimes(0); +}); + +test(`sets downstream plugin's status to green when initialize resolves`, (done) => { + const pluginId = 'foo-plugin'; + const { mockXpackMainPlugin, mockFeature } = createMockXpackMainPluginAndFeature(pluginId); + mockXpackMainPlugin.mock.setStatus('green', 'green is now the state'); + const licenseCheckResults = Symbol(); + mockFeature.mock.setLicenseCheckResults(licenseCheckResults); + const downstreamPlugin = createMockDownstreamPlugin(pluginId); + const initializeMock = jest.fn().mockImplementation(() => Promise.resolve()); + + watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledWith(licenseCheckResults); + downstreamPlugin.status.green.mockImplementation(actualMessage => + { + expect(actualMessage).toBe('Ready'); + done(); + }); +}); + +test(`sets downstream plugin's status to red when initialize rejects`, (done) => { + const pluginId = 'foo-plugin'; + const errorMessage = 'the error message'; + const { mockXpackMainPlugin, mockFeature } = createMockXpackMainPluginAndFeature(pluginId); + mockXpackMainPlugin.mock.setStatus('green'); + const licenseCheckResults = Symbol(); + mockFeature.mock.setLicenseCheckResults(licenseCheckResults); + const downstreamPlugin = createMockDownstreamPlugin(pluginId); + const initializeMock = jest.fn().mockImplementation(() => Promise.reject(new Error(errorMessage))); + + watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledWith(licenseCheckResults); + downstreamPlugin.status.red.mockImplementation(message => { + expect(message).toBe(errorMessage); + done(); + }); +}); + +test(`calls initialize twice when it gets a new license and the status is green`, (done) => { + const pluginId = 'foo-plugin'; + const { mockXpackMainPlugin, mockFeature } = createMockXpackMainPluginAndFeature(pluginId); + mockXpackMainPlugin.mock.setStatus('green'); + const firstLicenseCheckResults = Symbol(); + const secondLicenseCheckResults = Symbol(); + mockFeature.mock.setLicenseCheckResults(firstLicenseCheckResults); + const downstreamPlugin = createMockDownstreamPlugin(pluginId); + const initializeMock = jest.fn().mockImplementation(() => Promise.resolve()); + + let count = 0; + downstreamPlugin.status.green.mockImplementation(message => { + expect(message).toBe('Ready'); + ++count; + if (count === 1) { + mockFeature.mock.setLicenseCheckResults(secondLicenseCheckResults); + mockFeature.mock.triggerLicenseChange(); + } + if (count === 2) { + expect(initializeMock).toHaveBeenCalledWith(firstLicenseCheckResults); + expect(initializeMock).toHaveBeenCalledWith(secondLicenseCheckResults); + expect(initializeMock).toHaveBeenCalledTimes(2); + done(); + } + }); + + watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); +}); + +test(`doesn't call initialize twice when it gets a new license when the status isn't green`, (done) => { + const pluginId = 'foo-plugin'; + const redMessage = 'the red message'; + const { mockXpackMainPlugin, mockFeature } = createMockXpackMainPluginAndFeature(pluginId); + mockXpackMainPlugin.mock.setStatus('green'); + const firstLicenseCheckResults = Symbol(); + const secondLicenseCheckResults = Symbol(); + mockFeature.mock.setLicenseCheckResults(firstLicenseCheckResults); + const downstreamPlugin = createMockDownstreamPlugin(pluginId); + const initializeMock = jest.fn().mockImplementation(() => Promise.resolve()); + + downstreamPlugin.status.green.mockImplementation(message => { + expect(message).toBe('Ready'); + mockXpackMainPlugin.mock.setStatus('red', redMessage); + mockFeature.mock.setLicenseCheckResults(secondLicenseCheckResults); + mockFeature.mock.triggerLicenseChange(); + }); + + downstreamPlugin.status.red.mockImplementation(message => { + expect(message).toBe(redMessage); + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledWith(firstLicenseCheckResults); + done(); + }); + + watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); +}); + +test(`calls initialize twice when the status changes to green twice`, (done) => { + const pluginId = 'foo-plugin'; + const { mockXpackMainPlugin, mockFeature } = createMockXpackMainPluginAndFeature(pluginId); + mockXpackMainPlugin.mock.setStatus('green'); + const licenseCheckResults = Symbol(); + mockFeature.mock.setLicenseCheckResults(licenseCheckResults); + const downstreamPlugin = createMockDownstreamPlugin(pluginId); + const initializeMock = jest.fn().mockImplementation(() => Promise.resolve()); + + let count = 0; + downstreamPlugin.status.green.mockImplementation(message => { + expect(message).toBe('Ready'); + ++count; + if (count === 1) { + mockXpackMainPlugin.mock.setStatus('green'); + } + if (count === 2) { + expect(initializeMock).toHaveBeenCalledWith(licenseCheckResults); + expect(initializeMock).toHaveBeenCalledTimes(2); + done(); + } + }); + + watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); +}); + diff --git a/x-pack/plugins/xpack_main/server/lib/__tests__/xpack_info.js b/x-pack/plugins/xpack_main/server/lib/__tests__/xpack_info.js index 4d896268d4d8a2..ded2fe0e5eab6e 100644 --- a/x-pack/plugins/xpack_main/server/lib/__tests__/xpack_info.js +++ b/x-pack/plugins/xpack_main/server/lib/__tests__/xpack_info.js @@ -67,7 +67,7 @@ describe('XPackInfo', () => { mockServer = sinon.stub({ plugins: { elasticsearch: mockElasticsearchPlugin }, - log() {} + log() { } }); }); @@ -151,9 +151,9 @@ describe('XPackInfo', () => { expect(xPackInfo.unavailableReason()).to.be(randomError); sinon.assert.calledWithExactly( mockServer.log, - [ 'license', 'warning', 'xpack' ], + ['license', 'warning', 'xpack'], `License information from the X-Pack plugin could not be obtained from Elasticsearch` + - ` for the [data] cluster. ${randomError}` + ` for the [data] cluster. ${randomError}` ); const badRequestError = new Error('Bad request'); @@ -168,9 +168,9 @@ describe('XPackInfo', () => { ); sinon.assert.calledWithExactly( mockServer.log, - [ 'license', 'warning', 'xpack' ], + ['license', 'warning', 'xpack'], `License information from the X-Pack plugin could not be obtained from Elasticsearch` + - ` for the [data] cluster. ${badRequestError}` + ` for the [data] cluster. ${badRequestError}` ); mockElasticsearchCluster.callWithInternalUser.returns(getMockXPackInfoAPIResponse()); @@ -462,6 +462,72 @@ describe('XPackInfo', () => { }); }); + it('registerLicenseChangeCallback() does not invoke callbacks if license has not changed', async () => { + const securityFeature = xPackInfo.feature('security'); + const watcherFeature = xPackInfo.feature('watcher'); + + const securityChangeCallback = sinon.stub(); + securityFeature.registerLicenseChangeCallback(securityChangeCallback); + + const watcherChangeCallback = sinon.stub(); + watcherFeature.registerLicenseChangeCallback(watcherChangeCallback); + + mockElasticsearchCluster.callWithInternalUser.returns( + getMockXPackInfoAPIResponse({ mode: 'gold' }) + ); + + await xPackInfo.refreshNow(); + + sinon.assert.notCalled(securityChangeCallback); + sinon.assert.notCalled(watcherChangeCallback); + }); + + it('registerLicenseChangeCallback() invokes callbacks on license change', async () => { + const securityFeature = xPackInfo.feature('security'); + const watcherFeature = xPackInfo.feature('watcher'); + + const securityChangeCallback = sinon.stub(); + securityFeature.registerLicenseChangeCallback(securityChangeCallback); + + const watcherChangeCallback = sinon.stub(); + watcherFeature.registerLicenseChangeCallback(watcherChangeCallback); + + mockElasticsearchCluster.callWithInternalUser.returns( + getMockXPackInfoAPIResponse({ mode: 'platinum' }) + ); + + await xPackInfo.refreshNow(); + + sinon.assert.calledOnce(securityChangeCallback); + sinon.assert.calledOnce(watcherChangeCallback); + }); + + it('registerLicenseChangeCallback() gracefully handles callbacks that throw errors', async () => { + const securityFeature = xPackInfo.feature('security'); + const watcherFeature = xPackInfo.feature('watcher'); + + const securityChangeCallback = sinon.stub().throws(new Error(`Something happened`)); + securityFeature.registerLicenseChangeCallback(securityChangeCallback); + + const watcherChangeCallback = sinon.stub(); + watcherFeature.registerLicenseChangeCallback(watcherChangeCallback); + + mockElasticsearchCluster.callWithInternalUser.returns( + getMockXPackInfoAPIResponse({ mode: 'platinum' }) + ); + + await xPackInfo.refreshNow(); + + sinon.assert.calledOnce(securityChangeCallback); + sinon.assert.calledOnce(watcherChangeCallback); + + sinon.assert.calledWithExactly( + mockServer.log, + ['license', 'error', 'xpack'], + `Error during invocation of license change callback for security. Error: Something happened` + ); + }); + it('getLicenseCheckResults() correctly returns feature specific info.', async () => { const securityFeature = xPackInfo.feature('security'); const watcherFeature = xPackInfo.feature('watcher'); diff --git a/x-pack/plugins/xpack_main/server/lib/xpack_info.js b/x-pack/plugins/xpack_main/server/lib/xpack_info.js index c1146295544391..84cf2807cbc078 100644 --- a/x-pack/plugins/xpack_main/server/lib/xpack_info.js +++ b/x-pack/plugins/xpack_main/server/lib/xpack_info.js @@ -28,6 +28,13 @@ export class XPackInfo { */ _featureLicenseCheckResultsGenerators = new Map(); + /** + * Feature name <-> license change callback mapping. + * @type {Map} + * @private + */ + _featureLicenseChangeCallbacks = new Map(); + /** * Cache that may contain last xpack info API response or error, json representation * of xpack info and xpack info signature. @@ -116,7 +123,8 @@ export class XPackInfo { path: '/_xpack' }); - if (this._hasLicenseInfoChanged(response)) { + const hasLicenseInfoChanged = this._hasLicenseInfoChanged(response); + if (hasLicenseInfoChanged) { const licenseInfoParts = [ `mode: ${get(response, 'license.mode')}`, `status: ${get(response, 'license.status')}`, @@ -132,16 +140,39 @@ export class XPackInfo { this._log( ['license', 'info', 'xpack'], `Imported ${this._cache.response ? 'changed ' : ''}license information` + - ` from Elasticsearch for the [${this._clusterSource}] cluster: ${licenseInfo}` + ` from Elasticsearch for the [${this._clusterSource}] cluster: ${licenseInfo}` ); } this._cache = { response }; - } catch(error) { + + if (hasLicenseInfoChanged) { + + // Invoke all registered license change callbacks after this instance receives the update + for (const [feature, callback] of this._featureLicenseChangeCallbacks) { + this._log( + ['license', 'debug', 'xpack'], + `Invoking license change callback for ${feature}` + ); + + const previousLicense = this._license; + const newLicense = new XPackInfoLicense(() => response && response.license); + + try { + callback(previousLicense, newLicense); + } catch (error) { + this._log( + ['license', 'error', 'xpack'], + `Error during invocation of license change callback for ${feature}. ${error}` + ); + } + } + } + } catch (error) { this._log( - [ 'license', 'warning', 'xpack' ], + ['license', 'warning', 'xpack'], `License information from the X-Pack plugin could not be obtained from Elasticsearch` + - ` for the [${this._clusterSource}] cluster. ${error}` + ` for the [${this._clusterSource}] cluster. ${error}` ); this._cache = { error }; @@ -191,6 +222,16 @@ export class XPackInfo { this._cache.signature = undefined; }, + /** + * Registers a callback function that will be called whenever the XPack license changes. + * Callback will be invoked after the license change have been applied to this XPack Info instance. + * Callbacks may be asynchronous, but will not be awaited. + * @param {Function} callback Function to call whenever the XPack license changes. + */ + registerLicenseChangeCallback: (callback) => { + this._featureLicenseChangeCallbacks.set(name, callback); + }, + /** * Returns license check results that were previously produced by the `generator` function. * @returns {Object} From 7ef58501a07b72183b2c7026bf8359e434886746 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Wed, 13 Jun 2018 16:25:00 -0400 Subject: [PATCH 043/183] RBAC Legacy Fallback (#19818) * Basic implementation, rather sloppy * Cleaning stuff up a bit * Beginning to write tests, going to refactor how we build the privileges * Making the buildPrivilegesMap no longer return application name as the main key * Using real privileges since we need to use them for the legacy fallback * Adding more tests * Fixing spelling * Fixing test description * Fixing comment description * Adding similar line breaks in the has privilege calls * No more settings * No more rbac enabled setting, we just do RBAC * Using describe to cleanup the test cases * Logging deprecations when using the legacy fallback * Cleaning up a bit... * Using the privilegeMap for the legacy fallback tests * Now with even less duplication * Removing stray `rbacEnabled` from angularjs --- x-pack/plugins/security/index.js | 68 +- .../public/views/management/edit_role.html | 2 +- .../public/views/management/edit_role.js | 3 +- .../lib/authorization/has_privileges.js | 134 +++- .../lib/authorization/has_privileges.test.js | 707 +++++++++++++----- .../security/server/lib/privileges/index.js | 2 +- .../privileges/privilege_action_registry.js | 4 +- .../privilege_action_registry.test.js | 6 +- .../server/lib/privileges/privileges.js | 4 +- .../server/routes/api/v1/privileges.js | 2 +- .../apis/saved_objects/bulk_get.js | 26 + .../apis/saved_objects/create.js | 26 + .../apis/saved_objects/delete.js | 34 + .../apis/saved_objects/find.js | 68 ++ .../apis/saved_objects/get.js | 34 + .../apis/saved_objects/index.js | 48 +- .../apis/saved_objects/lib/authentication.js | 8 + .../apis/saved_objects/update.js | 34 + x-pack/test/rbac_api_integration/config.js | 1 - 19 files changed, 961 insertions(+), 250 deletions(-) diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 0c60bd282b9e4d..e0449711f31fc6 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -44,7 +44,6 @@ export const security = (kibana) => new kibana.Plugin({ port: Joi.number().integer().min(0).max(65535) }).default(), rbac: Joi.object({ - enabled: Joi.boolean().default(false), application: Joi.string().default('kibana').regex( /[a-zA-Z0-9-_]+/, `may contain alphanumeric characters (a-z, A-Z, 0-9), underscores and hyphens` @@ -81,7 +80,6 @@ export const security = (kibana) => new kibana.Plugin({ return { secureCookies: config.get('xpack.security.secureCookies'), sessionTimeout: config.get('xpack.security.sessionTimeout'), - rbacEnabled: config.get('xpack.security.rbac.enabled'), rbacApplication: config.get('xpack.security.rbac.application'), }; } @@ -116,50 +114,48 @@ export const security = (kibana) => new kibana.Plugin({ server.auth.strategy('session', 'login', 'required'); const auditLogger = new SecurityAuditLogger(server.config(), new AuditLogger(server, 'security')); - if (config.get('xpack.security.rbac.enabled')) { - const hasPrivilegesWithRequest = hasPrivilegesWithServer(server); - const { savedObjects } = server; + const hasPrivilegesWithRequest = hasPrivilegesWithServer(server); + const { savedObjects } = server; - savedObjects.setScopedSavedObjectsClientFactory(({ - request, - index, - mappings, - onBeforeWrite - }) => { - const adminCluster = server.plugins.elasticsearch.getCluster('admin'); - - if (!xpackInfoFeature.getLicenseCheckResults().allowRbac) { - const { callWithRequest } = adminCluster; - const callCluster = (...args) => callWithRequest(request, ...args); - - const repository = new savedObjects.SavedObjectsRepository({ - index, - mappings, - onBeforeWrite, - callCluster, - }); - - return new savedObjects.SavedObjectsClient(repository); - } + savedObjects.setScopedSavedObjectsClientFactory(({ + request, + index, + mappings, + onBeforeWrite + }) => { + const adminCluster = server.plugins.elasticsearch.getCluster('admin'); - const hasPrivileges = hasPrivilegesWithRequest(request); - const { callWithInternalUser } = adminCluster; + if (!xpackInfoFeature.getLicenseCheckResults().allowRbac) { + const { callWithRequest } = adminCluster; + const callCluster = (...args) => callWithRequest(request, ...args); const repository = new savedObjects.SavedObjectsRepository({ index, mappings, onBeforeWrite, - callCluster: callWithInternalUser + callCluster, }); - return new SecureSavedObjectsClient({ - repository, - errors: savedObjects.SavedObjectsClient.errors, - hasPrivileges, - auditLogger, - }); + return new savedObjects.SavedObjectsClient(repository); + } + + const hasPrivileges = hasPrivilegesWithRequest(request); + const { callWithInternalUser } = adminCluster; + + const repository = new savedObjects.SavedObjectsRepository({ + index, + mappings, + onBeforeWrite, + callCluster: callWithInternalUser }); - } + + return new SecureSavedObjectsClient({ + repository, + errors: savedObjects.SavedObjectsClient.errors, + hasPrivileges, + auditLogger, + }); + }); getUserProvider(server); diff --git a/x-pack/plugins/security/public/views/management/edit_role.html b/x-pack/plugins/security/public/views/management/edit_role.html index 8c8c5d692cce21..da03371ae6cbd4 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.html +++ b/x-pack/plugins/security/public/views/management/edit_role.html @@ -111,7 +111,7 @@

-
+
diff --git a/x-pack/plugins/security/public/views/management/edit_role.js b/x-pack/plugins/security/public/views/management/edit_role.js index 77316db93bbd26..0aaebc83e6f72f 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.js +++ b/x-pack/plugins/security/public/views/management/edit_role.js @@ -118,7 +118,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { } }, controllerAs: 'editRole', - controller($injector, $scope, rbacEnabled, rbacApplication) { + controller($injector, $scope, rbacApplication) { const $route = $injector.get('$route'); const kbnUrl = $injector.get('kbnUrl'); const shieldPrivileges = $injector.get('shieldPrivileges'); @@ -132,7 +132,6 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { $scope.indexPatterns = $route.current.locals.indexPatterns; $scope.privileges = shieldPrivileges; - $scope.rbacEnabled = rbacEnabled; const kibanaApplicationPrivilege = $route.current.locals.kibanaApplicationPrivilege; const role = $route.current.locals.role; $scope.kibanaPrivileges = getKibanaPrivileges(kibanaApplicationPrivilege, role, rbacApplication); diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.js index 5f119db027cb6f..fdb00e63d29f25 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.js @@ -6,11 +6,87 @@ import { getClient } from '../../../../../server/lib/get_client_shield'; import { DEFAULT_RESOURCE } from '../../../common/constants'; -import { getVersionPrivilege, getLoginPrivilege } from '../privileges'; +import { buildPrivilegeMap, getVersionPrivilege, getLoginPrivilege } from '../privileges'; -const getMissingPrivileges = (resource, application, privilegeCheck) => { - const privileges = privilegeCheck.application[application][resource]; - return Object.keys(privileges).filter(key => privileges[key] === false); +const hasApplicationPrivileges = async (callWithRequest, request, kibanaVersion, application, privileges) => { + const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application, + resources: [DEFAULT_RESOURCE], + privileges + }] + } + }); + + const hasPrivileges = privilegeCheck.application[application][DEFAULT_RESOURCE]; + + // We include the login action in all privileges, so the existence of it and not the version privilege + // lets us know that we're running in an incorrect configuration. Without the login privilege check, we wouldn't + // know whether the user just wasn't authorized for this instance of Kibana in general + if (!hasPrivileges[getVersionPrivilege(kibanaVersion)] && hasPrivileges[getLoginPrivilege()]) { + throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.'); + } + + return { + username: privilegeCheck.username, + hasAllRequested: privilegeCheck.has_all_requested, + privileges: hasPrivileges + }; +}; + +const hasLegacyPrivileges = async (deprecationLogger, callWithRequest, request, kibanaVersion, application, kibanaIndex, privileges) => { + const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { + body: { + index: [{ + names: [ kibanaIndex ], + privileges: ['read', 'index'] + }] + } + }); + + const createPrivileges = (cb) => { + return privileges.reduce((acc, name) => { + acc[name] = cb(name); + return acc; + }, {}); + }; + + const logDeprecation = () => { + deprecationLogger( + `Relying on implicit privileges determined from the index privileges is deprecated and will be removed in the next major version` + ); + }; + + // if they have the index privilege, then we grant them all actions + if (privilegeCheck.index[kibanaIndex].index) { + logDeprecation(); + const implicitPrivileges = createPrivileges(() => true); + return { + username: privilegeCheck.username, + hasAllRequested: true, + privileges: implicitPrivileges + }; + } + + // if they have the read privilege, then we only grant them the read actions + if (privilegeCheck.index[kibanaIndex].read) { + logDeprecation(); + const privilegeMap = buildPrivilegeMap(application, kibanaVersion); + const implicitPrivileges = createPrivileges(name => privilegeMap.read.actions.includes(name)); + + return { + username: privilegeCheck.username, + hasAllRequested: Object.values(implicitPrivileges).every(x => x), + privileges: implicitPrivileges, + }; + } + + return { + username: privilegeCheck.username, + hasAllRequested: false, + privileges: createPrivileges(() => false) + }; }; export function hasPrivilegesWithServer(server) { @@ -19,38 +95,44 @@ export function hasPrivilegesWithServer(server) { const config = server.config(); const kibanaVersion = config.get('pkg.version'); const application = config.get('xpack.security.rbac.application'); + const kibanaIndex = config.get('kibana.index'); + const deprecationLogger = (msg) => server.log(['warning', 'deprecated', 'security'], msg); return function hasPrivilegesWithRequest(request) { return async function hasPrivileges(privileges) { - - const versionPrivilege = getVersionPrivilege(kibanaVersion); const loginPrivilege = getLoginPrivilege(); + const versionPrivilege = getVersionPrivilege(kibanaVersion); - const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application, - resources: [DEFAULT_RESOURCE], - privileges: [versionPrivilege, loginPrivilege, ...privileges] - }] - } - }); - - const success = privilegeCheck.has_all_requested; - const missingPrivileges = getMissingPrivileges(DEFAULT_RESOURCE, application, privilegeCheck); - - // We include the login privilege on all privileges, so the existence of it and not the version privilege - // lets us know that we're running in an incorrect configuration. Without the login privilege check, we wouldn't - // know whether the user just wasn't authorized for this instance of Kibana in general - if (missingPrivileges.includes(versionPrivilege) && !missingPrivileges.includes(loginPrivilege)) { - throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.'); + const allPrivileges = [versionPrivilege, loginPrivilege, ...privileges]; + let privilegesCheck = await hasApplicationPrivileges( + callWithRequest, + request, + kibanaVersion, + application, + allPrivileges + ); + + if (!privilegesCheck.privileges[loginPrivilege]) { + privilegesCheck = await hasLegacyPrivileges( + deprecationLogger, + callWithRequest, + request, + kibanaVersion, + application, + kibanaIndex, + allPrivileges + ); } + const success = privilegesCheck.hasAllRequested; + return { success, // We don't want to expose the version privilege to consumers, as it's an implementation detail only to detect version mismatch - missing: missingPrivileges.filter(p => p !== versionPrivilege), - username: privilegeCheck.username, + missing: Object.keys(privilegesCheck.privileges) + .filter(key => privilegesCheck.privileges[key] === false) + .filter(p => p !== versionPrivilege), + username: privilegesCheck.username, }; }; }; diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js index b2e2759c3419b3..2462b0a4434607 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js @@ -4,23 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ +import { sample } from 'lodash'; import { hasPrivilegesWithServer } from './has_privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; import { DEFAULT_RESOURCE } from '../../../common/constants'; -import { getLoginPrivilege, getVersionPrivilege } from '../privileges'; +import { getLoginPrivilege, getVersionPrivilege, buildPrivilegeMap } from '../privileges'; jest.mock('../../../../../server/lib/get_client_shield', () => ({ getClient: jest.fn() })); -let mockCallWithRequest; -beforeEach(() => { - mockCallWithRequest = jest.fn(); - getClient.mockReturnValue({ - callWithRequest: mockCallWithRequest - }); -}); - +const defaultKibanaIndex = 'default-kibana-index'; const defaultVersion = 'default-version'; const defaultApplication = 'default-application'; @@ -28,10 +22,12 @@ const createMockServer = ({ settings = {} } = {}) => { const mockServer = { config: jest.fn().mockReturnValue({ get: jest.fn() - }) + }), + log: jest.fn() }; const defaultSettings = { + 'kibana.index': defaultKibanaIndex, 'pkg.version': defaultVersion, 'xpack.security.rbac.application': defaultApplication }; @@ -43,8 +39,8 @@ const createMockServer = ({ settings = {} } = {}) => { return mockServer; }; -const mockResponse = (hasAllRequested, privileges, application = defaultApplication, username = '') => { - mockCallWithRequest.mockImplementationOnce(async () => ({ +const mockApplicationPrivilegeResponse = ({ hasAllRequested, privileges, application = defaultApplication, username = '' }) =>{ + return { username: username, has_all_requested: hasAllRequested, application: { @@ -52,215 +48,578 @@ const mockResponse = (hasAllRequested, privileges, application = defaultApplicat [DEFAULT_RESOURCE]: privileges } } - })); + }; }; - -test(`calls shield.hasPrivileges with request`, async () => { - const mockServer = createMockServer(); - mockResponse(true, { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - foo: true, - }); - - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const request = {}; - const hasPrivileges = hasPrivilegesWithRequest(request); - await hasPrivileges(['foo']); - - expect(mockCallWithRequest).toHaveBeenCalledWith(request, expect.anything(), expect.anything()); -}); - -test(`calls shield.hasPrivileges with clientParams`, async () => { - const application = 'foo-application'; - const version = 'foo-version'; - const mockServer = createMockServer({ - settings: { - 'xpack.security.rbac.application': application, - 'pkg.version': version +const mockLegacyResponse = ({ hasAllRequested, privileges, index = defaultKibanaIndex, username = '' }) => { + return { + username: username, + has_all_requested: hasAllRequested, + index: { + [index]: privileges } + }; +}; + +const createMockCallWithRequest = (responses) => { + const mockCallWithRequest = jest.fn(); + getClient.mockReturnValue({ + callWithRequest: mockCallWithRequest }); - mockResponse(true, { - [getVersionPrivilege(version)]: true, - [getLoginPrivilege()]: true, - foo: true, - }, application); + for (const response of responses) { + mockCallWithRequest.mockImplementationOnce(async () => response); + } - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const hasPrivileges = hasPrivilegesWithRequest({}); + return mockCallWithRequest; +}; - const privilege = 'foo'; - await hasPrivileges([privilege]); +const expectNoDeprecationLogged = (mockServer) => { + expect(mockServer.log).not.toHaveBeenCalled(); +}; - const clientParams = mockCallWithRequest.mock.calls[0][2]; - const applicationParam = clientParams.body.applications[0]; - expect(applicationParam).toHaveProperty('application', application); - expect(applicationParam).toHaveProperty('resources', [DEFAULT_RESOURCE]); - expect(applicationParam).toHaveProperty('privileges'); - expect(applicationParam.privileges).toContain(privilege); -}); +const expectDeprecationLogged = (mockServer) => { + expect(mockServer.log).toHaveBeenCalledWith(['warning', 'deprecated', 'security'], expect.stringContaining('deprecated')); +}; -test(`includes version privilege when checking privileges`, async () => { +test(`returns success of true if they have all application privileges`, async () => { + const privilege = 'action:saved_objects/config/get'; + const username = 'foo-username'; const mockServer = createMockServer(); - mockResponse(true, { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - foo: true, - }); + const mockCallWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: true, + privileges: { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + [privilege]: true, + }, + application: defaultApplication, + username, + }) + ]); const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const request = {}; + const request = Symbol(); const hasPrivileges = hasPrivilegesWithRequest(request); - await hasPrivileges(['foo']); - - const clientParams = mockCallWithRequest.mock.calls[0][2]; - const applicationParam = clientParams.body.applications[0]; - expect(applicationParam.privileges).toContain(getVersionPrivilege(defaultVersion)); + const privileges = [privilege]; + const result = await hasPrivileges(privileges); + + expectNoDeprecationLogged(mockServer); + expect(mockCallWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(result).toEqual({ + success: true, + missing: [], + username + }); }); -test(`includes login privilege when checking privileges`, async () => { +test(`returns success of false if they have only one application privilege`, async () => { + const privilege1 = 'action:saved_objects/config/get'; + const privilege2 = 'action:saved_objects/config/create'; + const username = 'foo-username'; const mockServer = createMockServer(); - mockResponse(true, { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - foo: true, - }); + const mockCallWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + [privilege1]: true, + [privilege2]: false, + }, + application: defaultApplication, + username, + }) + ]); const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const request = {}; + const request = Symbol(); const hasPrivileges = hasPrivilegesWithRequest(request); - await hasPrivileges(['foo']); - - const clientParams = mockCallWithRequest.mock.calls[0][2]; - const applicationParam = clientParams.body.applications[0]; - expect(applicationParam.privileges).toContain(getLoginPrivilege()); -}); - -test(`returns success when has_all_requested`, async () => { - const mockServer = createMockServer(); - mockResponse(true, { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - foo: true, + const privileges = [privilege1, privilege2]; + const result = await hasPrivileges(privileges); + + expectNoDeprecationLogged(mockServer); + expect(mockCallWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(result).toEqual({ + success: false, + missing: [privilege2], + username }); - - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const hasPrivileges = hasPrivilegesWithRequest({}); - const result = await hasPrivileges(['foo']); - expect(result.success).toBe(true); }); -test(`returns username from has_privileges response when has_all_requested`, async () => { +test(`throws error if missing version privilege and has login privilege`, async () => { + const privilege = 'action:saved_objects/config/get'; const mockServer = createMockServer(); - const username = 'foo-username'; - mockResponse(true, { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - foo: true, - }, defaultApplication, username); + createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: false, + [getLoginPrivilege()]: true, + [privilege]: true, + } + }) + ]); const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); const hasPrivileges = hasPrivilegesWithRequest({}); - const result = await hasPrivileges(['foo']); - expect(result.username).toBe(username); -}); -test(`returns false success when has_all_requested is false`, async () => { - const mockServer = createMockServer(); - mockResponse(false, { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - foo: false, - }); - - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const hasPrivileges = hasPrivilegesWithRequest({}); - const result = await hasPrivileges(['foo']); - expect(result.success).toBe(false); + await expect(hasPrivileges([privilege])).rejects.toThrowErrorMatchingSnapshot(); + expectNoDeprecationLogged(mockServer); }); -test(`returns username from has_privileges when has_all_requested is false`, async () => { +test(`uses application privileges if the user has the login privilege`, async () => { + const privilege = 'action:saved_objects/config/get'; const username = 'foo-username'; const mockServer = createMockServer(); - mockResponse(false, { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - foo: false, - }, defaultApplication, username); + const callWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + [privilege]: false, + }, + username, + }), + ]); const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const hasPrivileges = hasPrivilegesWithRequest({ }); - const result = await hasPrivileges(['foo']); - expect(result.username).toBe(username); -}); - -test(`returns missing privileges`, async () => { - const mockServer = createMockServer(); - mockResponse(false, { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - foo: false, + const request = Symbol(); + const hasPrivileges = hasPrivilegesWithRequest(request); + const privileges = [privilege]; + const result = await hasPrivileges(privileges); + + expectNoDeprecationLogged(mockServer); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(result).toEqual({ + success: false, + missing: [...privileges], + username, }); - - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const hasPrivileges = hasPrivilegesWithRequest({}); - const result = await hasPrivileges(['foo']); - expect(result.missing).toEqual(['foo']); }); -test(`excludes granted privileges from missing privileges`, async () => { +test(`returns success of false using application privileges if the user has the login privilege`, async () => { + const privilege = 'action:saved_objects/config/get'; + const username = 'foo-username'; const mockServer = createMockServer(); - mockResponse(false, { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - foo: false, - bar: true, - }); + const callWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + [privilege]: false, + }, + username, + }), + ]); const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const hasPrivileges = hasPrivilegesWithRequest({}); - const result = await hasPrivileges(['foo']); - expect(result.missing).toEqual(['foo']); + const request = Symbol(); + const hasPrivileges = hasPrivilegesWithRequest(request); + const privileges = [privilege]; + const result = await hasPrivileges(privileges); + + expectNoDeprecationLogged(mockServer); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(result).toEqual({ + success: false, + missing: [...privileges], + username, + }); }); -test(`throws error if missing version privilege and has login privilege`, async () => { - const mockServer = createMockServer(); - mockResponse(false, { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: true, - foo: true, +describe('legacy fallback with no application privileges', () => { + test(`returns success of false if the user has no legacy privileges`, async () => { + const privilege = 'action:saved_objects/config/get'; + const username = 'foo-username'; + const mockServer = createMockServer(); + const callWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: false, + [getLoginPrivilege()]: false, + [privilege]: false, + }, + username, + }), + mockLegacyResponse({ + hasAllRequested: false, + privileges: { + read: false, + index: false, + }, + username, + }) + ]); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const request = Symbol(); + const hasPrivileges = hasPrivilegesWithRequest(request); + const privileges = [privilege]; + const result = await hasPrivileges(privileges); + + expectNoDeprecationLogged(mockServer); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + index: [{ + names: [ defaultKibanaIndex ], + privileges: ['read', 'index'] + }] + } + }); + expect(result).toEqual({ + success: false, + missing: [getLoginPrivilege(), ...privileges], + username, + }); }); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const hasPrivileges = hasPrivilegesWithRequest({}); - await expect(hasPrivileges(['foo'])).rejects.toThrowErrorMatchingSnapshot(); -}); + test(`returns success of true if the user has index privilege on kibana index`, async () => { + const privilege = 'something-completely-arbitrary'; + const username = 'foo-username'; + const mockServer = createMockServer(); + const callWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: false, + [getLoginPrivilege()]: false, + [privilege]: false, + }, + username, + }), + mockLegacyResponse({ + hasAllRequested: false, + privileges: { + read: false, + index: true, + }, + username, + }) + ]); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const request = Symbol(); + const hasPrivileges = hasPrivilegesWithRequest(request); + const privileges = ['foo']; + const result = await hasPrivileges(privileges); + + expectDeprecationLogged(mockServer); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + index: [{ + names: [ defaultKibanaIndex ], + privileges: ['read', 'index'] + }] + } + }); + expect(result).toEqual({ + success: true, + missing: [], + username, + }); + }); -test(`doesn't throw error if missing version privilege and missing login privilege`, async () => { - const mockServer = createMockServer(); - mockResponse(false, { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: false, - foo: true, + test(`returns success of false if the user has the read privilege on kibana index but the privilege isn't a read action`, async () => { + const privilege = 'something-completely-arbitrary'; + const username = 'foo-username'; + const mockServer = createMockServer(); + const callWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: false, + [getLoginPrivilege()]: false, + [privilege]: false, + }, + username, + }), + mockLegacyResponse({ + hasAllRequested: false, + privileges: { + read: true, + index: false, + }, + username, + }) + ]); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const request = Symbol(); + const hasPrivileges = hasPrivilegesWithRequest(request); + const privileges = [privilege]; + const result = await hasPrivileges(privileges); + + expectDeprecationLogged(mockServer); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + index: [{ + names: [ defaultKibanaIndex ], + privileges: ['read', 'index'] + }] + } + }); + expect(result).toEqual({ + success: false, + missing: [ privilege ], + username, + }); }); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const hasPrivileges = hasPrivilegesWithRequest({}); - await hasPrivileges(['foo']); -}); + test(`returns success of false if the user has the read privilege on kibana index but one privilege isn't a read action`, async () => { + const privilegeMap = buildPrivilegeMap(defaultApplication, defaultVersion); + const privilege1 = 'something-completely-arbitrary'; + const privilege2 = sample(privilegeMap.read.actions); + const username = 'foo-username'; + const mockServer = createMockServer(); + const callWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: false, + [getLoginPrivilege()]: false, + [privilege1]: false, + [privilege2]: true + }, + username, + }), + mockLegacyResponse({ + hasAllRequested: false, + privileges: { + read: true, + index: false, + }, + username, + }) + ]); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const request = Symbol(); + const hasPrivileges = hasPrivilegesWithRequest(request); + const privileges = [privilege1, privilege2]; + const result = await hasPrivileges(privileges); + + expectDeprecationLogged(mockServer); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + index: [{ + names: [ defaultKibanaIndex ], + privileges: ['read', 'index'] + }] + } + }); + expect(result).toEqual({ + success: false, + missing: [ privilege1 ], + username, + }); + }); -test(`excludes version privilege when missing version privilege and missing login privilege`, async () => { - const mockServer = createMockServer(); - mockResponse(false, { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: false, - foo: true, + test(`returns success of true if the user has the read privilege on kibana index and the privilege is a read action`, async () => { + const privilegeMap = buildPrivilegeMap(defaultApplication, defaultVersion); + for (const action of privilegeMap.read.actions) { + const privilege = action; + const username = 'foo-username'; + const mockServer = createMockServer(); + const callWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: false, + [getLoginPrivilege()]: false, + [privilege]: false, + }, + username, + }), + mockLegacyResponse({ + hasAllRequested: false, + privileges: { + read: true, + index: false, + }, + username, + }) + ]); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const request = Symbol(); + const hasPrivileges = hasPrivilegesWithRequest(request); + const privileges = [privilege]; + const result = await hasPrivileges(privileges); + + expectDeprecationLogged(mockServer); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + index: [{ + names: [ defaultKibanaIndex ], + privileges: ['read', 'index'] + }] + } + }); + expect(result).toEqual({ + success: true, + missing: [], + username, + }); + } }); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const hasPrivileges = hasPrivilegesWithRequest({}); - const result = await hasPrivileges(['foo']); - expect(result.missing).toEqual([getLoginPrivilege()]); + test(`returns success of true if the user has the read privilege on kibana index and all privileges are read actions`, async () => { + const privilegeMap = buildPrivilegeMap(defaultApplication, defaultVersion); + const privileges = privilegeMap.read.actions; + const username = 'foo-username'; + const mockServer = createMockServer(); + const callWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: false, + [getLoginPrivilege()]: false, + ...privileges.reduce((acc, name) => { + acc[name] = false; + return acc; + }, {}) + }, + username, + }), + mockLegacyResponse({ + hasAllRequested: false, + privileges: { + read: true, + index: false, + }, + username, + }) + ]); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const request = Symbol(); + const hasPrivileges = hasPrivilegesWithRequest(request); + const result = await hasPrivileges(privileges); + + expectDeprecationLogged(mockServer); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + index: [{ + names: [ defaultKibanaIndex ], + privileges: ['read', 'index'] + }] + } + }); + expect(result).toEqual({ + success: true, + missing: [], + username, + }); + }); }); diff --git a/x-pack/plugins/security/server/lib/privileges/index.js b/x-pack/plugins/security/server/lib/privileges/index.js index a270b8a60331e9..a7a2455d5ec3b0 100644 --- a/x-pack/plugins/security/server/lib/privileges/index.js +++ b/x-pack/plugins/security/server/lib/privileges/index.js @@ -5,4 +5,4 @@ */ export { registerPrivilegesWithCluster } from './privilege_action_registry'; -export { getLoginPrivilege, getVersionPrivilege } from './privileges'; +export { buildPrivilegeMap, getLoginPrivilege, getVersionPrivilege } from './privileges'; diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js index 7d31b287fe664c..c91a8e097f6916 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js @@ -16,7 +16,9 @@ export async function registerPrivilegesWithCluster(server) { const kibanaVersion = config.get('pkg.version'); const application = config.get('xpack.security.rbac.application'); - const expectedPrivileges = buildPrivilegeMap(application, kibanaVersion); + const expectedPrivileges = { + [application]: buildPrivilegeMap(application, kibanaVersion) + }; server.log(['security', 'debug'], `Registering Kibana Privileges with Elasticsearch for ${application}`); diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js index 398a68a097ed94..74949536eb943c 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js @@ -55,7 +55,9 @@ const registerPrivilegesWithClusterTest = (description, { settings = {}, expecte expect(mockCallWithInternalUser).toHaveBeenCalledWith( 'shield.postPrivileges', { - body: privileges, + body: { + [defaultApplication]: privileges + }, } ); @@ -93,7 +95,7 @@ const registerPrivilegesWithClusterTest = (description, { settings = {}, expecte test(description, async () => { const mockServer = createMockServer(); const mockCallWithInternalUser = registerMockCallWithInternalUser(); - mockCallWithInternalUser.mockImplementationOnce(async () => (existingPrivileges)); + mockCallWithInternalUser.mockImplementationOnce(async () => ({ [defaultApplication]: existingPrivileges })); buildPrivilegeMap.mockReturnValue(expectedPrivileges); await registerPrivilegesWithCluster(mockServer); diff --git a/x-pack/plugins/security/server/lib/privileges/privileges.js b/x-pack/plugins/security/server/lib/privileges/privileges.js index 8c11014b4ae14f..e70d30f2e87fa6 100644 --- a/x-pack/plugins/security/server/lib/privileges/privileges.js +++ b/x-pack/plugins/security/server/lib/privileges/privileges.js @@ -35,9 +35,7 @@ export function buildPrivilegeMap(application, kibanaVersion) { metadata: {} }; - return { - [application]: privilegeActions - }; + return privilegeActions; } function buildSavedObjectsReadPrivileges() { diff --git a/x-pack/plugins/security/server/routes/api/v1/privileges.js b/x-pack/plugins/security/server/routes/api/v1/privileges.js index f7d6e7c6a00394..6c095da482dce4 100644 --- a/x-pack/plugins/security/server/routes/api/v1/privileges.js +++ b/x-pack/plugins/security/server/routes/api/v1/privileges.js @@ -23,7 +23,7 @@ export function initPrivilegesApi(server) { // with our view of them to allow users to more efficiently edit privileges for roles, and serialize it // into a different structure for enforcement within Elasticsearch const privileges = buildPrivilegeMap(application, kibanaVersion); - reply(Object.values(privileges[application])); + reply(Object.values(privileges)); } }); } diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js b/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js index 18fe5a015dc785..12a25607a92acd 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js @@ -120,6 +120,32 @@ export default function ({ getService }) { } }); + bulkGetTest(`kibana legacy user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, + }, + tests: { + default: { + statusCode: 200, + response: expectResults, + }, + } + }); + + bulkGetTest(`kibana legacy dashboard only user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, + }, + tests: { + default: { + statusCode: 200, + response: expectResults, + }, + } + }); + bulkGetTest(`kibana rbac user`, { auth: { username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/create.js b/x-pack/test/rbac_api_integration/apis/saved_objects/create.js index 0db0fc41b5c4a6..c8ca5be09b6ad4 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/create.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/create.js @@ -82,6 +82,32 @@ export default function ({ getService }) { } }); + createTest(`kibana legacy user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, + }, + tests: { + default: { + statusCode: 200, + response: expectResults, + }, + } + }); + + createTest(`kibana legacy dashboard only user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, + }, + tests: { + default: { + statusCode: 403, + response: createExpectForbidden(true), + }, + } + }); + createTest(`kibana rbac user`, { auth: { username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js b/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js index ea73c927b869e5..f89073acca7fda 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js @@ -90,6 +90,40 @@ export default function ({ getService }) { } }); + deleteTest(`kibana legacy user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, + }, + tests: { + actualId: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: expectNotFound, + } + } + }); + + deleteTest(`kibana legacy dashboard only user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, + }, + tests: { + actualId: { + statusCode: 403, + response: createExpectForbidden(true), + }, + invalidId: { + statusCode: 403, + response: createExpectForbidden(true), + } + } + }); + deleteTest(`kibana rbac user`, { auth: { username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/find.js b/x-pack/test/rbac_api_integration/apis/saved_objects/find.js index e00dd1aa907155..59cc3dc12d5389 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/find.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/find.js @@ -217,6 +217,74 @@ export default function ({ getService }) { }, }); + findTest(`kibana legacy user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, + }, + tests: { + normal: { + description: 'only the visualization', + statusCode: 200, + response: expectVisualizationResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'all objects', + statusCode: 200, + response: expectAllResults, + }, + }, + }); + + findTest(`kibana legacy dashboard only user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, + }, + tests: { + normal: { + description: 'only the visualization', + statusCode: 200, + response: expectVisualizationResults, + }, + unknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectActionForbidden(true, 'wigwags'), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectActionForbidden(true, 'wigwags'), + }, + noType: { + description: 'all objects', + statusCode: 200, + response: expectAllResults, + }, + } + }); + findTest(`kibana rbac user`, { auth: { username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/get.js b/x-pack/test/rbac_api_integration/apis/saved_objects/get.js index e5a462d30d30cb..029a44475b12e6 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/get.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/get.js @@ -106,6 +106,40 @@ export default function ({ getService }) { } }); + getTest(`kibana legacy user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, + }, + tests: { + exists: { + statusCode: 200, + response: expectResults, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + } + }); + + getTest(`kibana legacy dashboard only user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, + }, + tests: { + exists: { + statusCode: 200, + response: expectResults, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + } + }); + getTest(`kibana rbac user`, { auth: { username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/index.js b/x-pack/test/rbac_api_integration/apis/saved_objects/index.js index 1199bd5986e66d..05f06088751ea3 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/index.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/index.js @@ -11,6 +11,30 @@ export default function ({ loadTestFile, getService }) { describe('saved_objects', () => { before(async () => { + await es.shield.putRole({ + name: 'kibana_legacy_user', + body: { + cluster: [], + index: [{ + names: ['.kibana'], + privileges: ['manage', 'read', 'index', 'delete'] + }], + applications: [] + } + }); + + await es.shield.putRole({ + name: 'kibana_legacy_dashboard_only_user', + body: { + cluster: [], + index: [{ + names: ['.kibana'], + privileges: ['read', 'view_index_metadata'] + }], + applications: [] + } + }); + await es.shield.putRole({ name: 'kibana_rbac_user', body: { @@ -51,13 +75,33 @@ export default function ({ loadTestFile, getService }) { } }); + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, + body: { + password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, + roles: ['kibana_legacy_user'], + full_name: 'a kibana legacy user', + email: 'a_kibana_legacy_user@elastic.co', + } + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, + body: { + password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, + roles: ["kibana_legacy_dashboard_only_user"], + full_name: 'a kibana legacy dashboard only user', + email: 'a_kibana_legacy_dashboard_only_user@elastic.co', + } + }); + await es.shield.putUser({ username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, body: { password: AUTHENTICATION.KIBANA_RBAC_USER.PASSWORD, roles: ['kibana_rbac_user'], full_name: 'a kibana user', - email: 'a_kibana_user@elastic.co', + email: 'a_kibana_rbac_user@elastic.co', } }); @@ -67,7 +111,7 @@ export default function ({ loadTestFile, getService }) { password: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.PASSWORD, roles: ["kibana_rbac_dashboard_only_user"], full_name: 'a kibana dashboard only user', - email: 'a_kibana_dashboard_only_user@elastic.co', + email: 'a_kibana_rbac_dashboard_only_user@elastic.co', } }); }); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/lib/authentication.js b/x-pack/test/rbac_api_integration/apis/saved_objects/lib/authentication.js index e095a032934eaf..8b140fd3b2a30c 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/lib/authentication.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/lib/authentication.js @@ -13,6 +13,14 @@ export const AUTHENTICATION = { USERNAME: 'elastic', PASSWORD: 'changeme' }, + KIBANA_LEGACY_USER: { + USERNAME: 'a_kibana_legacy_user', + PASSWORD: 'password' + }, + KIBANA_LEGACY_DASHBOARD_ONLY_USER: { + USERNAME: 'a_kibana_legacy_dashboard_only_user', + PASSWORD: 'password' + }, KIBANA_RBAC_USER: { USERNAME: 'a_kibana_rbac_user', PASSWORD: 'password' diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/update.js b/x-pack/test/rbac_api_integration/apis/saved_objects/update.js index a9f5f0ab81aaba..ae299348847d69 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/update.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/update.js @@ -114,6 +114,40 @@ export default function ({ getService }) { } }); + updateTest(`kibana legacy user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, + }, + tests: { + exists: { + statusCode: 200, + response: expectResults, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + } + }); + + updateTest(`kibana legacy dashboard only user`, { + auth: { + username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, + password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, + }, + tests: { + exists: { + statusCode: 403, + response: createExpectForbidden(true), + }, + doesntExist: { + statusCode: 403, + response: createExpectForbidden(true), + }, + } + }); + updateTest(`kibana rbac user`, { auth: { username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, diff --git a/x-pack/test/rbac_api_integration/config.js b/x-pack/test/rbac_api_integration/config.js index b9f3f19b0576c9..c2ff96adcff559 100644 --- a/x-pack/test/rbac_api_integration/config.js +++ b/x-pack/test/rbac_api_integration/config.js @@ -54,7 +54,6 @@ export default async function ({ readConfigFile }) { serverArgs: [ ...config.xpack.api.get('kbnTestServer.serverArgs'), '--optimize.enabled=false', - '--xpack.security.rbac.enabled=true', '--server.xsrf.disableProtection=true', ], }, From 9941eb2b32dcfc6604ab88ad5b054d2f54474d47 Mon Sep 17 00:00:00 2001 From: kobelb Date: Wed, 13 Jun 2018 16:55:41 -0400 Subject: [PATCH 044/183] Fixing checkLicenses tests since we added RBAC --- .../server/lib/__tests__/check_license.js | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security/server/lib/__tests__/check_license.js b/x-pack/plugins/security/server/lib/__tests__/check_license.js index 63f25022267745..53e3eb09eefdfa 100644 --- a/x-pack/plugins/security/server/lib/__tests__/check_license.js +++ b/x-pack/plugins/security/server/lib/__tests__/check_license.js @@ -35,6 +35,7 @@ describe('check_license', function () { showLinks: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, + allowRbac: false, loginMessage: 'Login is currently disabled. Administrators should consult the Kibana logs for more details.' }); }); @@ -53,6 +54,7 @@ describe('check_license', function () { showLinks: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, + allowRbac: false, linksMessage: 'Your Basic license does not support Security. Please upgrade your license.' }); }); @@ -71,6 +73,7 @@ describe('check_license', function () { showLinks: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, + allowRbac: false, linksMessage: 'Access is denied because Security is disabled in Elasticsearch.' }); }); @@ -94,7 +97,8 @@ describe('check_license', function () { allowLogin: true, showLinks: true, allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false + allowRoleFieldLevelSecurity: false, + allowRbac: true, }); mockXPackInfo.license.isActive.returns(false); @@ -103,7 +107,8 @@ describe('check_license', function () { allowLogin: true, showLinks: true, allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false + allowRoleFieldLevelSecurity: false, + allowRbac: true, }); }); @@ -121,7 +126,8 @@ describe('check_license', function () { allowLogin: true, showLinks: true, allowRoleDocumentLevelSecurity: true, - allowRoleFieldLevelSecurity: true + allowRoleFieldLevelSecurity: true, + allowRbac: true, }); mockXPackInfo.license.isActive.returns(false); @@ -130,7 +136,8 @@ describe('check_license', function () { allowLogin: true, showLinks: true, allowRoleDocumentLevelSecurity: true, - allowRoleFieldLevelSecurity: true + allowRoleFieldLevelSecurity: true, + allowRbac: true, }); }); @@ -148,7 +155,8 @@ describe('check_license', function () { allowLogin: true, showLinks: true, allowRoleDocumentLevelSecurity: true, - allowRoleFieldLevelSecurity: true + allowRoleFieldLevelSecurity: true, + allowRbac: true, }); mockXPackInfo.license.isActive.returns(false); @@ -157,7 +165,8 @@ describe('check_license', function () { allowLogin: true, showLinks: true, allowRoleDocumentLevelSecurity: true, - allowRoleFieldLevelSecurity: true + allowRoleFieldLevelSecurity: true, + allowRbac: true, }); }); }); From 8667ebd816e1f4c2522fba625e64abc3932bdbf1 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 14 Jun 2018 08:15:23 -0400 Subject: [PATCH 045/183] [Flaky Test] - wait for page load to complete (#19895) @kobelb this seems unrelated to our RBAC Phase 1 work, but I was able to consistently reproduce this on my machine. --- x-pack/test/functional/apps/monitoring/elasticsearch/shards.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/test/functional/apps/monitoring/elasticsearch/shards.js b/x-pack/test/functional/apps/monitoring/elasticsearch/shards.js index e2557bf4b4de13..ea5fd1ad227303 100644 --- a/x-pack/test/functional/apps/monitoring/elasticsearch/shards.js +++ b/x-pack/test/functional/apps/monitoring/elasticsearch/shards.js @@ -33,6 +33,8 @@ export default function ({ getService, getPageObjects }) { // start on cluster overview await PageObjects.monitoring.clickBreadcrumb('breadcrumbClusters'); + await PageObjects.header.waitUntilLoadingHasFinished(); + // go to nodes listing await overview.clickEsNodes(); expect(await nodesList.isOnListing()).to.be(true); From b0e699899915a2a7c53993fe55cbed8f56e1ff3e Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 14 Jun 2018 10:01:45 -0400 Subject: [PATCH 046/183] [Flaky Test] Fixes flaky role test (#19899) Here's a fix for the latest flaky test @kobelb --- x-pack/test/functional/apps/security/management.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/x-pack/test/functional/apps/security/management.js b/x-pack/test/functional/apps/security/management.js index b05e5f39098fba..d01d5e2b99d5df 100644 --- a/x-pack/test/functional/apps/security/management.js +++ b/x-pack/test/functional/apps/security/management.js @@ -136,6 +136,9 @@ export default function ({ getService, getPageObjects }) { }); it('Reserved roles are not editable', async () => { + // wait for role tab to finish loading from previous test + await PageObjects.header.waitUntilLoadingHasFinished(); + const allInputs = await find.allByCssSelector('input'); for (let i = 0; i < allInputs.length; i++) { const input = allInputs[i]; From 91899182134854d17804fe517b1e2d70291d4720 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 14 Jun 2018 11:22:30 -0400 Subject: [PATCH 047/183] updates from merge --- .../__snapshots__/space_selector.test.js.snap | 5 +- .../spaces/server/lib/create_default_space.js | 14 ++-- .../server/lib/create_default_space.test.js | 57 +++++++++------ x-pack/yarn.lock | 69 +++---------------- 4 files changed, 55 insertions(+), 90 deletions(-) diff --git a/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap b/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap index 7de6fd437aabc2..766dda7129df20 100644 --- a/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap +++ b/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap @@ -2,7 +2,8 @@ exports[`it renders without crashing 1`] = `
{ const createMockServer = (settings = {}) => { const { - defaultExists = false + defaultExists = false, + simulateErrorCondition = false } = settings; + const mockGet = jest.fn().mockImplementation(() => { + if (simulateErrorCondition) { + throw new Error('unit test: unexpected exception condition'); + } + + if (defaultExists) { + return; + } + throw Boom.notFound('unit test: default space not found'); + }); + + const mockCreate = jest.fn().mockReturnValue(); + const mockServer = { config: jest.fn().mockReturnValue({ get: jest.fn() }), - savedObjectsClientFactory: jest.fn().mockReturnValue({ - get: jest.fn().mockImplementation(() => { - if (defaultExists) { - return; + savedObjects: { + SavedObjectsClient: { + errors: { + isNotFoundError: (e) => e.message === 'unit test: default space not found' } - throw Boom.notFound('unit test: default space not found'); - }), - create: jest.fn().mockReturnValue(), - errors: { - isNotFoundError: (e) => e.message === 'unit test: default space not found' - } - }) + }, + getSavedObjectsRepository: jest.fn().mockImplementation(() => { + return { + get: mockGet, + create: mockCreate, + }; + }) + } }; mockServer.config().get.mockImplementation(key => { @@ -57,11 +72,11 @@ test(`it creates the default space when one does not exist`, async () => { await createDefaultSpace(server); - const client = server.savedObjectsClientFactory(); + const repository = server.savedObjects.getSavedObjectsRepository(); - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.create).toHaveBeenCalledTimes(1); - expect(client.create).toHaveBeenCalledWith( + expect(repository.get).toHaveBeenCalledTimes(1); + expect(repository.create).toHaveBeenCalledTimes(1); + expect(repository.create).toHaveBeenCalledWith( 'space', { "_reserved": true, "description": "This is your Default Space!", "name": "Default Space", "urlContext": "" }, { "id": "default" } @@ -75,20 +90,18 @@ test(`it does not attempt to recreate the default space if it already exists`, a await createDefaultSpace(server); - const client = server.savedObjectsClientFactory(); + const repository = server.savedObjects.getSavedObjectsRepository(); - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.create).toHaveBeenCalledTimes(0); + expect(repository.get).toHaveBeenCalledTimes(1); + expect(repository.create).toHaveBeenCalledTimes(0); }); test(`it throws all other errors from the saved objects client`, async () => { const server = createMockServer({ defaultExists: true, + simulateErrorCondition: true, }); - const client = server.savedObjectsClientFactory(); - client.get = () => { throw new Error('unit test: unexpected exception condition'); }; - try { await createDefaultSpace(server); throw new Error(`Expected error to be thrown!`); diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index bc2e273b5d3c4b..298f2bfb168528 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -10,15 +10,9 @@ esutils "^2.0.2" js-tokens "^3.0.0" -<<<<<<< HEAD -"@elastic/eui@v0.0.51": - version "0.0.51" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-0.0.51.tgz#5d809af270dd9994a609fd01eaa84e21a62fff98" -======= "@elastic/eui@0.0.52": version "0.0.52" resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-0.0.52.tgz#c34c6cd482b458015e0d2b410f98652053994138" ->>>>>>> master dependencies: classnames "^2.2.5" core-js "^2.5.1" @@ -1323,8 +1317,6 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0: escape-string-regexp "^1.0.5" supports-color "^4.0.0" -<<<<<<< HEAD -======= chalk@^2.3.1, chalk@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" @@ -1333,7 +1325,6 @@ chalk@^2.3.1, chalk@^2.4.1: escape-string-regexp "^1.0.5" supports-color "^5.3.0" ->>>>>>> master chalk@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.2.tgz#250dc96b07491bfd601e648d66ddf5f60c7a5c65" @@ -1873,9 +1864,6 @@ debug@2.2.0: dependencies: ms "0.7.1" -<<<<<<< HEAD -debug@2.X, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8: -======= debug@2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.0.tgz#bc596bcabe7617f11d9fa15361eded5608b8499b" @@ -1883,7 +1871,6 @@ debug@2.6.0: ms "0.7.2" debug@^2.2.0, debug@^2.3.3, debug@^2.6.8: ->>>>>>> master version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" dependencies: @@ -2146,8 +2133,6 @@ elasticsearch@13.0.1: lodash.isempty "^4.4.0" lodash.trimend "^4.5.1" -<<<<<<< HEAD -======= elasticsearch@^14.1.0: version "14.2.2" resolved "https://registry.yarnpkg.com/elasticsearch/-/elasticsearch-14.2.2.tgz#6bbb63b19b17fa97211b22eeacb0f91197f4d6b6" @@ -2159,7 +2144,6 @@ elasticsearch@^14.1.0: lodash.isempty "^4.4.0" lodash.trimend "^4.5.1" ->>>>>>> master encoding@^0.1.11: version "0.1.12" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" @@ -2336,13 +2320,10 @@ esutils@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/esutils/-/esutils-1.0.0.tgz#8151d358e20c8acc7fb745e7472c0025fe496570" -<<<<<<< HEAD -======= eventemitter3@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" ->>>>>>> master exec-sh@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.1.tgz#163b98a6e89e6b65b47c2a28d215bc1f63989c38" @@ -2739,8 +2720,6 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" -<<<<<<< HEAD -======= from2@^2.1.1: version "2.3.0" resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" @@ -2748,7 +2727,6 @@ from2@^2.1.1: inherits "^2.0.1" readable-stream "^2.0.0" ->>>>>>> master from@^0.1.3: version "0.1.7" resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" @@ -2839,11 +2817,7 @@ get-stdin@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" -<<<<<<< HEAD -get-stream@^3.0.0: -======= get-stream@3.0.0, get-stream@^3.0.0: ->>>>>>> master version "3.0.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" @@ -3480,13 +3454,10 @@ htmlparser2@^3.9.1: inherits "^2.0.1" readable-stream "^2.0.2" -<<<<<<< HEAD -======= http-cache-semantics@3.8.1: version "3.8.1" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2" ->>>>>>> master http-errors@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.4.0.tgz#6c0242dea6b3df7afda153c71089b31c6e82aabf" @@ -3847,13 +3818,10 @@ is-relative@^1.0.0: dependencies: is-unc-path "^1.0.0" -<<<<<<< HEAD -======= is-retry-allowed@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz#11a060568b67339444033d0125a61a20d564fb34" ->>>>>>> master is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -4916,11 +4884,7 @@ methods@^1.1.1, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" -<<<<<<< HEAD -micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7: -======= micromatch@^2.1.5, micromatch@^2.3.11: ->>>>>>> master version "2.3.11" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" dependencies: @@ -5244,13 +5208,10 @@ node-pre-gyp@^0.6.39: tar "^2.2.1" tar-pack "^3.4.0" -<<<<<<< HEAD -======= nodemailer@^4.6.4: version "4.6.4" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.6.4.tgz#f0d72d0c6a6ec5f4369fa8f4bf5127a31baa2014" ->>>>>>> master nomnom@~1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.6.2.tgz#84a66a260174408fc5b77a18f888eccc44fb6971" @@ -5739,6 +5700,10 @@ pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + pinkie-promise@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" @@ -6747,27 +6712,6 @@ rxjs@5.3.0: dependencies: symbol-observable "^1.0.1" -<<<<<<< HEAD -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" - -safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - -"safer-buffer@>= 2.1.2 < 3": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - -samsam@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" - -samsam@~1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.3.tgz#9f5087419b4d091f232571e7fa52e90b0f552621" -======= rxjs@^5.4.3: version "5.5.10" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.10.tgz#fde02d7a614f6c8683d0d1957827f492e09db045" @@ -6778,10 +6722,13 @@ safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + samsam@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" ->>>>>>> master sane@^2.0.0: version "2.2.0" From 3dd4b3b3aeb9755bd14ab12ceb6d016015f824e9 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 14 Jun 2018 11:25:17 -0400 Subject: [PATCH 048/183] updates from merge -- add missing files --- .../saved_objects/saved_objects_mixin.js | 1 - yarn.lock | 76 ++----------------- 2 files changed, 5 insertions(+), 72 deletions(-) diff --git a/src/server/saved_objects/saved_objects_mixin.js b/src/server/saved_objects/saved_objects_mixin.js index bc5d625551a1e3..ed51f920176f67 100644 --- a/src/server/saved_objects/saved_objects_mixin.js +++ b/src/server/saved_objects/saved_objects_mixin.js @@ -64,7 +64,6 @@ export function savedObjectsMixin(kbnServer, server) { const savedObjectsClient = server.savedObjects.getScopedSavedObjectsClient(request); - savedObjectsClientCache.set(request, savedObjectsClient); return savedObjectsClient; }); diff --git a/yarn.lock b/yarn.lock index 57f46584ab63ca..a1bcbf1ac7e7fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -77,15 +77,9 @@ version "0.0.0" uid "" -<<<<<<< HEAD -"@elastic/eui@v0.0.51": - version "0.0.51" - resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-0.0.51.tgz#5d809af270dd9994a609fd01eaa84e21a62fff98" -======= "@elastic/eui@0.0.52", "@elastic/eui@v0.0.52": version "0.0.52" resolved "https://registry.yarnpkg.com/@elastic/eui/-/eui-0.0.52.tgz#c34c6cd482b458015e0d2b410f98652053994138" ->>>>>>> master dependencies: classnames "^2.2.5" core-js "^2.5.1" @@ -311,8 +305,6 @@ version "9.4.7" resolved "https://registry.yarnpkg.com/@types/node/-/node-9.4.7.tgz#57d81cd98719df2c9de118f2d5f3b1120dcd7275" -<<<<<<< HEAD -======= "@types/node@^9.4.7": version "9.6.18" resolved "https://registry.yarnpkg.com/@types/node/-/node-9.6.18.tgz#092e13ef64c47e986802c9c45a61c1454813b31d" @@ -363,7 +355,6 @@ "@types/events" "*" "@types/node" "*" ->>>>>>> master JSONStream@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.1.1.tgz#c98bfd88c8f1e1e8694e53c5baa6c8691553e59a" @@ -1796,19 +1787,11 @@ bluebird@3.4.6: version "3.4.6" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.6.tgz#01da8d821d87813d158967e743d5fe6c62cf8c0f" -<<<<<<< HEAD -bluebird@^2.10.0, bluebird@^2.9.24: - version "2.11.0" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" - -bluebird@^3.3.0: -======= bluebird@^2.10.0: version "2.11.0" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" bluebird@^3.3.0, bluebird@^3.3.1: ->>>>>>> master version "3.5.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" @@ -2083,13 +2066,8 @@ buffer-equal@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe" buffer-from@^1.0.0: -<<<<<<< HEAD - version "1.1.0" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz#87fcaa3a298358e0ade6e442cfce840740d1ad04" -======= version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.0.0.tgz#4cb8832d23612589b0406e9e2956c17f06fdf531" ->>>>>>> master buffer-xor@^1.0.3: version "1.0.3" @@ -2364,8 +2342,6 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.3.1, chalk@^2.3 escape-string-regexp "^1.0.5" supports-color "^5.3.0" -<<<<<<< HEAD -======= chalk@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" @@ -2374,7 +2350,6 @@ chalk@^2.4.1: escape-string-regexp "^1.0.5" supports-color "^5.3.0" ->>>>>>> master chalk@~0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.5.1.tgz#663b3a648b68b55d04690d49167aa837858f2174" @@ -2847,20 +2822,7 @@ concat-stream@1.6.0: readable-stream "^2.2.2" typedarray "^0.0.6" -<<<<<<< HEAD -concat-stream@^1.4.7: - version "1.6.2" - resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" - dependencies: - buffer-from "^1.0.0" - inherits "^2.0.3" - readable-stream "^2.2.2" - typedarray "^0.0.6" - -concat-stream@^1.5.2, concat-stream@^1.6.0, concat-stream@~1.6.0: -======= concat-stream@^1.4.7, concat-stream@^1.6.0, concat-stream@~1.6.0: ->>>>>>> master version "1.6.1" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.1.tgz#261b8f518301f1d834e36342b9fea095d2620a26" dependencies: @@ -5134,8 +5096,6 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" -<<<<<<< HEAD -======= from2@^2.1.1: version "2.3.0" resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" @@ -5143,7 +5103,6 @@ from2@^2.1.1: inherits "^2.0.1" readable-stream "^2.0.0" ->>>>>>> master from@^0.1.3, from@~0: version "0.1.7" resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" @@ -5158,15 +5117,9 @@ fs-exists-sync@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add" -<<<<<<< HEAD -fs-extra@4.0.3, fs-extra@^4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" -======= fs-extra@6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-6.0.0.tgz#0f0afb290bb3deb87978da816fcd3c7797f3a817" ->>>>>>> master dependencies: graceful-fs "^4.1.2" jsonfile "^4.0.0" @@ -8974,13 +8927,10 @@ node-status-codes@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f" -<<<<<<< HEAD -======= nodemailer@^4.6.4: version "4.6.4" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-4.6.4.tgz#f0d72d0c6a6ec5f4369fa8f4bf5127a31baa2014" ->>>>>>> master nomnom@~1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.6.2.tgz#84a66a260174408fc5b77a18f888eccc44fb6971" @@ -10465,15 +10415,13 @@ react-input-range@^1.3.0: autobind-decorator "^1.3.4" prop-types "^15.5.8" -<<<<<<< HEAD -react-lifecycles-compat@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" -======= react-is@^16.4.0: version "16.4.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.4.0.tgz#cc9fdc855ac34d2e7d9d2eb7059bbc240d35ffcf" ->>>>>>> master + +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" react-markdown-renderer@^1.4.0: version "1.4.0" @@ -11309,9 +11257,6 @@ rxjs@5.4.3: dependencies: symbol-observable "^1.0.1" -<<<<<<< HEAD -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1: -======= rxjs@^5.4.3: version "5.5.10" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.10.tgz#fde02d7a614f6c8683d0d1957827f492e09db045" @@ -11330,8 +11275,7 @@ rxjs@^6.1.0: dependencies: tslib "^1.9.0" -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1: ->>>>>>> master +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" @@ -11356,23 +11300,13 @@ safefs@^4.0.0: editions "^1.1.1" graceful-fs "^4.1.4" -<<<<<<< HEAD "safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" -samsam@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.2.tgz#bec11fdc83a9fda063401210e40176c3024d1567" - -samsam@~1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.1.3.tgz#9f5087419b4d091f232571e7fa52e90b0f552621" -======= samsam@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" ->>>>>>> master sane@^2.0.0: version "2.5.0" From b464f03e65fa6dd41bc4445a851552fa89b37537 Mon Sep 17 00:00:00 2001 From: kobelb Date: Thu, 14 Jun 2018 11:37:43 -0400 Subject: [PATCH 049/183] Now with even easier repository access --- x-pack/plugins/security/index.js | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index e0449711f31fc6..9bc4d34697c022 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -119,9 +119,6 @@ export const security = (kibana) => new kibana.Plugin({ savedObjects.setScopedSavedObjectsClientFactory(({ request, - index, - mappings, - onBeforeWrite }) => { const adminCluster = server.plugins.elasticsearch.getCluster('admin'); @@ -129,12 +126,7 @@ export const security = (kibana) => new kibana.Plugin({ const { callWithRequest } = adminCluster; const callCluster = (...args) => callWithRequest(request, ...args); - const repository = new savedObjects.SavedObjectsRepository({ - index, - mappings, - onBeforeWrite, - callCluster, - }); + const repository = savedObjects.getSavedObjectsRepository(callCluster); return new savedObjects.SavedObjectsClient(repository); } @@ -142,12 +134,7 @@ export const security = (kibana) => new kibana.Plugin({ const hasPrivileges = hasPrivilegesWithRequest(request); const { callWithInternalUser } = adminCluster; - const repository = new savedObjects.SavedObjectsRepository({ - index, - mappings, - onBeforeWrite, - callCluster: callWithInternalUser - }); + const repository = savedObjects.getSavedObjectsRepository(callWithInternalUser); return new SecureSavedObjectsClient({ repository, From e02c5bbbf72bc9d5d2795fb15476209597baf348 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Thu, 14 Jun 2018 13:38:29 -0400 Subject: [PATCH 050/183] Sample was including login/version privileges, which was occasionally (#19915) causing issues that were really hard to replicate --- .../lib/authorization/has_privileges.test.js | 112 +++++++++--------- 1 file changed, 57 insertions(+), 55 deletions(-) diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js index 2462b0a4434607..98ed3713922fd2 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { sample } from 'lodash'; import { hasPrivilegesWithServer } from './has_privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; import { DEFAULT_RESOURCE } from '../../../common/constants'; @@ -445,62 +444,65 @@ describe('legacy fallback with no application privileges', () => { test(`returns success of false if the user has the read privilege on kibana index but one privilege isn't a read action`, async () => { const privilegeMap = buildPrivilegeMap(defaultApplication, defaultVersion); - const privilege1 = 'something-completely-arbitrary'; - const privilege2 = sample(privilegeMap.read.actions); - const username = 'foo-username'; - const mockServer = createMockServer(); - const callWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: false, - [privilege1]: false, - [privilege2]: true - }, - username, - }), - mockLegacyResponse({ - hasAllRequested: false, - privileges: { - read: true, - index: false, - }, - username, - }) - ]); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); - const privileges = [privilege1, privilege2]; - const result = await hasPrivileges(privileges); + const actions = privilegeMap.read.actions.filter(a => a !== getVersionPrivilege(defaultVersion) && a !== getLoginPrivilege()); + for (const action of actions) { + const privilege1 = 'something-completely-arbitrary'; + const privilege2 = action; + const username = 'foo-username'; + const mockServer = createMockServer(); + const callWithRequest = createMockCallWithRequest([ + mockApplicationPrivilegeResponse({ + hasAllRequested: false, + privileges: { + [getVersionPrivilege(defaultVersion)]: false, + [getLoginPrivilege()]: false, + [privilege1]: false, + [privilege2]: true + }, + username, + }), + mockLegacyResponse({ + hasAllRequested: false, + privileges: { + read: true, + index: false, + }, + username, + }) + ]); + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const request = Symbol(); + const hasPrivileges = hasPrivilegesWithRequest(request); + const privileges = [privilege1, privilege2]; + const result = await hasPrivileges(privileges); - expectDeprecationLogged(mockServer); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [DEFAULT_RESOURCE], - privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges - ] - }] - } - }); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - index: [{ - names: [ defaultKibanaIndex ], - privileges: ['read', 'index'] - }] - } - }); - expect(result).toEqual({ - success: false, - missing: [ privilege1 ], - username, - }); + expectDeprecationLogged(mockServer); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [DEFAULT_RESOURCE], + privileges: [ + getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + ] + }] + } + }); + expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + index: [{ + names: [ defaultKibanaIndex ], + privileges: ['read', 'index'] + }] + } + }); + expect(result).toEqual({ + success: false, + missing: [ privilege1 ], + username, + }); + } }); test(`returns success of true if the user has the read privilege on kibana index and the privilege is a read action`, async () => { From 9cdf641130bfedcaa906349f06e8d816a94cabb8 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Fri, 15 Jun 2018 09:16:41 -0400 Subject: [PATCH 051/183] Dynamic types (#19925) No more hard-coded types! This will make it so that plugins that register their own mappings just transparently work. --- .../service/create_saved_objects_service.js | 7 +- .../saved_objects/service/lib/repository.js | 6 +- .../service/lib/repository.test.js | 77 ------------------- x-pack/plugins/security/index.js | 1 + .../lib/authorization/has_privileges.js | 15 +++- .../lib/authorization/has_privileges.test.js | 25 +++--- .../privileges/privilege_action_registry.js | 3 +- .../privilege_action_registry.test.js | 20 ++++- .../server/lib/privileges/privileges.js | 13 ++-- .../secure_saved_objects_client.js | 4 +- .../secure_saved_objects_client.test.js | 14 ++-- .../server/routes/api/v1/privileges.js | 3 +- 12 files changed, 71 insertions(+), 117 deletions(-) diff --git a/src/server/saved_objects/service/create_saved_objects_service.js b/src/server/saved_objects/service/create_saved_objects_service.js index ff68b5eaee2833..fd471cad2dd23d 100644 --- a/src/server/saved_objects/service/create_saved_objects_service.js +++ b/src/server/saved_objects/service/create_saved_objects_service.js @@ -17,6 +17,7 @@ * under the License. */ +import { getRootPropertiesObjects } from '../../mappings'; import { SavedObjectsRepository, ScopedSavedObjectsClientProvider, SavedObjectsRepositoryProvider } from './lib'; import { SavedObjectsClient } from './saved_objects_client'; @@ -58,15 +59,16 @@ export function createSavedObjectsService(server) { } }; + const mappings = server.getKibanaIndexMappingsDsl(); const repositoryProvider = new SavedObjectsRepositoryProvider({ index: server.config().get('kibana.index'), - mappings: server.getKibanaIndexMappingsDsl(), + mappings, onBeforeWrite, }); const scopedClientProvider = new ScopedSavedObjectsClientProvider({ index: server.config().get('kibana.index'), - mappings: server.getKibanaIndexMappingsDsl(), + mappings, onBeforeWrite, defaultClientFactory({ request, @@ -81,6 +83,7 @@ export function createSavedObjectsService(server) { }); return { + types: Object.keys(getRootPropertiesObjects(mappings)), SavedObjectsClient, SavedObjectsRepository, getSavedObjectsRepository: (...args) => diff --git a/src/server/saved_objects/service/lib/repository.js b/src/server/saved_objects/service/lib/repository.js index b7675adb5239af..6f3b4fffb21586 100644 --- a/src/server/saved_objects/service/lib/repository.js +++ b/src/server/saved_objects/service/lib/repository.js @@ -19,7 +19,7 @@ import uuid from 'uuid'; -import { getRootType, getRootPropertiesObjects } from '../../../mappings'; +import { getRootType } from '../../../mappings'; import { getSearchDsl } from './search_dsl'; import { trimIdPrefix } from './trim_id_prefix'; import { includedFields } from './included_fields'; @@ -406,10 +406,6 @@ export class SavedObjectsRepository { }; } - getTypes() { - return Object.keys(getRootPropertiesObjects(this._mappings)); - } - async _writeToCluster(method, params) { try { await this._onBeforeWrite(); diff --git a/src/server/saved_objects/service/lib/repository.test.js b/src/server/saved_objects/service/lib/repository.test.js index d3e66c0554e438..a02bfb9abb6194 100644 --- a/src/server/saved_objects/service/lib/repository.test.js +++ b/src/server/saved_objects/service/lib/repository.test.js @@ -632,83 +632,6 @@ describe('SavedObjectsRepository', () => { }); }); - describe('#getTypes', () => { - it(`returns no types if mappings have no types`, () => { - const mappings = { - doc: { - properties: { - 'updated_at': { - type: 'date' - }, - } - } - }; - - savedObjectsRepository = new SavedObjectsRepository({ - index: '.kibana-test', - mappings, - callCluster: callAdminCluster, - onBeforeWrite - }); - - const types = savedObjectsRepository.getTypes(); - expect(types).toEqual([]); - }); - - it(`returns single type defined in mappings`, () => { - const mappings = { - doc: { - properties: { - 'updated_at': { - type: 'date' - }, - 'index-pattern': { - properties: {} - } - } - } - }; - - savedObjectsRepository = new SavedObjectsRepository({ - index: '.kibana-test', - mappings, - callCluster: callAdminCluster, - onBeforeWrite - }); - - const types = savedObjectsRepository.getTypes(); - expect(types).toEqual(['index-pattern']); - }); - - it(`returns multiple types defined in mappings`, () => { - const mappings = { - doc: { - properties: { - 'updated_at': { - type: 'date' - }, - 'index-pattern': { - properties: {} - }, - 'visualization': { - properties: {} - }, - } - } - }; - - savedObjectsRepository = new SavedObjectsRepository({ - index: '.kibana-test', - mappings, - callCluster: callAdminCluster, - onBeforeWrite - }); - - const types = savedObjectsRepository.getTypes(); - expect(types).toEqual(['index-pattern', 'visualization']); - }); - }); - describe('onBeforeWrite', () => { it('blocks calls to callCluster of requests', async () => { onBeforeWrite.returns(delay(500)); diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 9bc4d34697c022..8e8ef10a9e2736 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -141,6 +141,7 @@ export const security = (kibana) => new kibana.Plugin({ errors: savedObjects.SavedObjectsClient.errors, hasPrivileges, auditLogger, + savedObjectTypes: savedObjects.types, }); }); diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.js index fdb00e63d29f25..79907d4e7b1e4e 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.js @@ -35,7 +35,16 @@ const hasApplicationPrivileges = async (callWithRequest, request, kibanaVersion, }; }; -const hasLegacyPrivileges = async (deprecationLogger, callWithRequest, request, kibanaVersion, application, kibanaIndex, privileges) => { +const hasLegacyPrivileges = async ( + savedObjectTypes, + deprecationLogger, + callWithRequest, + request, + kibanaVersion, + application, + kibanaIndex, + privileges +) => { const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { body: { index: [{ @@ -72,7 +81,7 @@ const hasLegacyPrivileges = async (deprecationLogger, callWithRequest, request, // if they have the read privilege, then we only grant them the read actions if (privilegeCheck.index[kibanaIndex].read) { logDeprecation(); - const privilegeMap = buildPrivilegeMap(application, kibanaVersion); + const privilegeMap = buildPrivilegeMap(savedObjectTypes, application, kibanaVersion); const implicitPrivileges = createPrivileges(name => privilegeMap.read.actions.includes(name)); return { @@ -96,6 +105,7 @@ export function hasPrivilegesWithServer(server) { const kibanaVersion = config.get('pkg.version'); const application = config.get('xpack.security.rbac.application'); const kibanaIndex = config.get('kibana.index'); + const savedObjectTypes = server.savedObjects.types; const deprecationLogger = (msg) => server.log(['warning', 'deprecated', 'security'], msg); return function hasPrivilegesWithRequest(request) { @@ -114,6 +124,7 @@ export function hasPrivilegesWithServer(server) { if (!privilegesCheck.privileges[loginPrivilege]) { privilegesCheck = await hasLegacyPrivileges( + savedObjectTypes, deprecationLogger, callWithRequest, request, diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js index 98ed3713922fd2..5203d477b16c81 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js @@ -16,6 +16,7 @@ jest.mock('../../../../../server/lib/get_client_shield', () => ({ const defaultKibanaIndex = 'default-kibana-index'; const defaultVersion = 'default-version'; const defaultApplication = 'default-application'; +const savedObjectTypes = ['foo-type', 'bar-type']; const createMockServer = ({ settings = {} } = {}) => { const mockServer = { @@ -35,6 +36,10 @@ const createMockServer = ({ settings = {} } = {}) => { return key in settings ? settings[key] : defaultSettings[key]; }); + mockServer.savedObjects = { + types: savedObjectTypes + }; + return mockServer; }; @@ -82,7 +87,7 @@ const expectDeprecationLogged = (mockServer) => { }; test(`returns success of true if they have all application privileges`, async () => { - const privilege = 'action:saved_objects/config/get'; + const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; const username = 'foo-username'; const mockServer = createMockServer(); const mockCallWithRequest = createMockCallWithRequest([ @@ -124,8 +129,8 @@ test(`returns success of true if they have all application privileges`, async () }); test(`returns success of false if they have only one application privilege`, async () => { - const privilege1 = 'action:saved_objects/config/get'; - const privilege2 = 'action:saved_objects/config/create'; + const privilege1 = `action:saved_objects/${savedObjectTypes[0]}/get`; + const privilege2 = `action:saved_objects/${savedObjectTypes[0]}/create`; const username = 'foo-username'; const mockServer = createMockServer(); const mockCallWithRequest = createMockCallWithRequest([ @@ -168,7 +173,7 @@ test(`returns success of false if they have only one application privilege`, asy }); test(`throws error if missing version privilege and has login privilege`, async () => { - const privilege = 'action:saved_objects/config/get'; + const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; const mockServer = createMockServer(); createMockCallWithRequest([ mockApplicationPrivilegeResponse({ @@ -189,7 +194,7 @@ test(`throws error if missing version privilege and has login privilege`, async }); test(`uses application privileges if the user has the login privilege`, async () => { - const privilege = 'action:saved_objects/config/get'; + const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; const username = 'foo-username'; const mockServer = createMockServer(); const callWithRequest = createMockCallWithRequest([ @@ -230,7 +235,7 @@ test(`uses application privileges if the user has the login privilege`, async () }); test(`returns success of false using application privileges if the user has the login privilege`, async () => { - const privilege = 'action:saved_objects/config/get'; + const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; const username = 'foo-username'; const mockServer = createMockServer(); const callWithRequest = createMockCallWithRequest([ @@ -272,7 +277,7 @@ test(`returns success of false using application privileges if the user has the describe('legacy fallback with no application privileges', () => { test(`returns success of false if the user has no legacy privileges`, async () => { - const privilege = 'action:saved_objects/config/get'; + const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; const username = 'foo-username'; const mockServer = createMockServer(); const callWithRequest = createMockCallWithRequest([ @@ -443,7 +448,7 @@ describe('legacy fallback with no application privileges', () => { }); test(`returns success of false if the user has the read privilege on kibana index but one privilege isn't a read action`, async () => { - const privilegeMap = buildPrivilegeMap(defaultApplication, defaultVersion); + const privilegeMap = buildPrivilegeMap(savedObjectTypes, defaultApplication, defaultVersion); const actions = privilegeMap.read.actions.filter(a => a !== getVersionPrivilege(defaultVersion) && a !== getLoginPrivilege()); for (const action of actions) { @@ -506,7 +511,7 @@ describe('legacy fallback with no application privileges', () => { }); test(`returns success of true if the user has the read privilege on kibana index and the privilege is a read action`, async () => { - const privilegeMap = buildPrivilegeMap(defaultApplication, defaultVersion); + const privilegeMap = buildPrivilegeMap(savedObjectTypes, defaultApplication, defaultVersion); for (const action of privilegeMap.read.actions) { const privilege = action; const username = 'foo-username'; @@ -566,7 +571,7 @@ describe('legacy fallback with no application privileges', () => { }); test(`returns success of true if the user has the read privilege on kibana index and all privileges are read actions`, async () => { - const privilegeMap = buildPrivilegeMap(defaultApplication, defaultVersion); + const privilegeMap = buildPrivilegeMap(savedObjectTypes, defaultApplication, defaultVersion); const privileges = privilegeMap.read.actions; const username = 'foo-username'; const mockServer = createMockServer(); diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js index c91a8e097f6916..27a00a56202132 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js @@ -15,9 +15,10 @@ export async function registerPrivilegesWithCluster(server) { const config = server.config(); const kibanaVersion = config.get('pkg.version'); const application = config.get('xpack.security.rbac.application'); + const savedObjectTypes = server.savedObjects.types; const expectedPrivileges = { - [application]: buildPrivilegeMap(application, kibanaVersion) + [application]: buildPrivilegeMap(savedObjectTypes, application, kibanaVersion) }; server.log(['security', 'debug'], `Registering Kibana Privileges with Elasticsearch for ${application}`); diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js index 74949536eb943c..c7aaa752eaa83b 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js @@ -14,7 +14,13 @@ jest.mock('./privileges', () => ({ buildPrivilegeMap: jest.fn(), })); -const registerPrivilegesWithClusterTest = (description, { settings = {}, expectedPrivileges, existingPrivileges, assert }) => { +const registerPrivilegesWithClusterTest = (description, { + settings = {}, + savedObjectTypes, + expectedPrivileges, + existingPrivileges, + assert +}) => { const registerMockCallWithInternalUser = () => { const callWithInternalUser = jest.fn(); getClient.mockReturnValue({ @@ -43,6 +49,10 @@ const registerPrivilegesWithClusterTest = (description, { settings = {}, expecte return key in settings ? settings[key] : defaultSettings[key]; }); + mockServer.savedObjects = { + types: savedObjectTypes + }; + return mockServer; }; @@ -110,13 +120,17 @@ const registerPrivilegesWithClusterTest = (description, { settings = {}, expecte }); }; -registerPrivilegesWithClusterTest(`passes application and kibanaVersion to buildPrivilegeMap`, { +registerPrivilegesWithClusterTest(`passes saved object types, application and kibanaVersion to buildPrivilegeMap`, { settings: { 'pkg.version': 'foo-version', 'xpack.security.rbac.application': 'foo-application', }, + savedObjectTypes: [ + 'foo-type', + 'bar-type', + ], assert: ({ mocks }) => { - expect(mocks.buildPrivilegeMap).toHaveBeenCalledWith('foo-application', 'foo-version'); + expect(mocks.buildPrivilegeMap).toHaveBeenCalledWith(['foo-type', 'bar-type'], 'foo-application', 'foo-version'); }, }); diff --git a/x-pack/plugins/security/server/lib/privileges/privileges.js b/x-pack/plugins/security/server/lib/privileges/privileges.js index e70d30f2e87fa6..4045b3e83da036 100644 --- a/x-pack/plugins/security/server/lib/privileges/privileges.js +++ b/x-pack/plugins/security/server/lib/privileges/privileges.js @@ -16,8 +16,8 @@ export function getLoginPrivilege() { return `action:login`; } -export function buildPrivilegeMap(application, kibanaVersion) { - const readSavedObjectsPrivileges = buildSavedObjectsReadPrivileges(); +export function buildPrivilegeMap(savedObjectTypes, application, kibanaVersion) { + const readSavedObjectsPrivileges = buildSavedObjectsReadPrivileges(savedObjectTypes); const privilegeActions = {}; @@ -38,14 +38,13 @@ export function buildPrivilegeMap(application, kibanaVersion) { return privilegeActions; } -function buildSavedObjectsReadPrivileges() { +function buildSavedObjectsReadPrivileges(savedObjectTypes) { const readActions = ['get', 'bulk_get', 'find']; - return buildSavedObjectsPrivileges(readActions); + return buildSavedObjectsPrivileges(savedObjectTypes, readActions); } -function buildSavedObjectsPrivileges(actions) { - const objectTypes = ['config', 'dashboard', 'graph-workspace', 'index-pattern', 'search', 'timelion-sheet', 'url', 'visualization']; - return objectTypes +function buildSavedObjectsPrivileges(savedObjectTypes, actions) { + return savedObjectTypes .map(type => actions.map(action => `action:saved_objects/${type}/${action}`)) .reduce((acc, types) => [...acc, ...types], []); } diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js index 53d8dadeb180b0..83e6123f06df5f 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js @@ -17,12 +17,14 @@ export class SecureSavedObjectsClient { repository, hasPrivileges, auditLogger, + savedObjectTypes, } = options; this.errors = errors; this._repository = repository; this._hasPrivileges = hasPrivileges; this._auditLogger = auditLogger; + this._savedObjectTypes = savedObjectTypes; } async create(type, attributes = {}, options = {}) { @@ -64,7 +66,7 @@ export class SecureSavedObjectsClient { } // otherwise, we have to filter for only their authorized types - const types = this._repository.getTypes(); + const types = this._savedObjectTypes; const typesToPrivilegesMap = new Map(types.map(type => [type, getPrivilege(type, action)])); const hasPrivilegesResult = await this._hasSavedObjectPrivileges(Array.from(typesToPrivilegesMap.values())); const authorizedTypes = Array.from(typesToPrivilegesMap.entries()) diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js index 036ed7a5e0c3af..0f9fa6610f683f 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js @@ -490,9 +490,7 @@ describe('#find', () => { test(`throws decorated ForbiddenError when user has no authorized types`, async () => { const type = 'foo'; const username = Symbol(); - const mockRepository = { - getTypes: jest.fn().mockReturnValue([type]) - }; + const mockRepository = {}; const mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ success: false, @@ -507,6 +505,7 @@ describe('#find', () => { repository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, + savedObjectTypes: [type] }); const options = Symbol(); @@ -529,9 +528,7 @@ describe('#find', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type1 = 'foo'; const type2 = 'bar'; - const mockRepository = { - getTypes: jest.fn().mockReturnValue([type1, type2]) - }; + const mockRepository = {}; const mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => { throw new Error(); @@ -542,6 +539,7 @@ describe('#find', () => { repository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, + savedObjectTypes: [type1, type2] }); await expect(client.find()).rejects.toThrowError(mockErrors.generalError); @@ -556,7 +554,6 @@ describe('#find', () => { const type1 = 'foo'; const type2 = 'bar'; const mockRepository = { - getTypes: jest.fn().mockReturnValue([type1, type2]), find: jest.fn(), }; const mockErrors = createMockErrors(); @@ -572,6 +569,7 @@ describe('#find', () => { repository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, + savedObjectTypes: [type1, type2] }); await client.find(); @@ -587,7 +585,6 @@ describe('#find', () => { const username = Symbol(); const returnValue = Symbol(); const mockRepository = { - getTypes: jest.fn().mockReturnValue([type]), find: jest.fn().mockReturnValue(returnValue) }; const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ @@ -600,6 +597,7 @@ describe('#find', () => { repository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, + savedObjectTypes: [type] }); const options = Symbol(); diff --git a/x-pack/plugins/security/server/routes/api/v1/privileges.js b/x-pack/plugins/security/server/routes/api/v1/privileges.js index 6c095da482dce4..54ce1f97110355 100644 --- a/x-pack/plugins/security/server/routes/api/v1/privileges.js +++ b/x-pack/plugins/security/server/routes/api/v1/privileges.js @@ -13,6 +13,7 @@ export function initPrivilegesApi(server) { const config = server.config(); const kibanaVersion = config.get('pkg.version'); const application = config.get('xpack.security.rbac.application'); + const savedObjectTypes = server.savedObjects.types; server.route({ method: 'GET', @@ -22,7 +23,7 @@ export function initPrivilegesApi(server) { // in Elasticsearch because our current thinking is that we'll associate additional structure/metadata // with our view of them to allow users to more efficiently edit privileges for roles, and serialize it // into a different structure for enforcement within Elasticsearch - const privileges = buildPrivilegeMap(application, kibanaVersion); + const privileges = buildPrivilegeMap(savedObjectTypes, application, kibanaVersion); reply(Object.values(privileges)); } }); From 50411b456664d1c14b7c13753880f2ac1005872b Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Fri, 15 Jun 2018 14:06:40 -0400 Subject: [PATCH 052/183] [Spaces] Allow Space Avatar to be customized (#19952) --- x-pack/plugins/spaces/common/constants.js | 2 + x-pack/plugins/spaces/common/index.js | 8 +- .../plugins/spaces/common/space_attributes.js | 54 ++++++ .../spaces/common/space_attributes.test.js | 81 ++++++++ x-pack/plugins/spaces/mappings.json | 6 + .../spaces/public/views/components/index.js | 7 + .../public/views/components/space_avatar.js | 24 +++ .../__snapshots__/page_header.test.js.snap | 20 +- .../components/delete_spaces_button.js | 14 +- .../views/management/components/index.js | 4 +- .../management/components/page_header.js | 14 +- .../customize_space_avatar.test.js.snap | 23 +++ .../__snapshots__/url_context.test.js.snap | 42 ++++ .../edit_space/customize_space_avatar.js | 131 +++++++++++++ .../edit_space/customize_space_avatar.test.js | 49 +++++ .../views/management/edit_space/index.js | 7 + .../manage_space_page.js | 181 +++++++++++------- .../reserved_space_badge.js | 11 +- .../reserved_space_badge.test.js | 4 +- .../{components => edit_space}/url_context.js | 4 +- .../management/edit_space/url_context.test.js | 22 +++ .../public/views/management/lib/index.js | 14 ++ .../views/management/lib/spaces_data_store.js | 35 ---- .../management/lib/spaces_data_store.test.js | 44 ----- .../views/management/lib/validate_space.js | 86 +++++++++ .../management/lib/validate_space.test.js | 84 ++++++++ .../views/management/manage_spaces.less | 13 +- .../public/views/management/page_routes.js | 16 +- .../views/management/spaces_grid/index.js | 6 + .../spaces_grid_page.js | 117 +++-------- .../plugins/spaces/server/lib/space_schema.js | 5 +- .../spaces/server/routes/api/v1/spaces.js | 10 +- 32 files changed, 853 insertions(+), 285 deletions(-) create mode 100644 x-pack/plugins/spaces/common/space_attributes.js create mode 100644 x-pack/plugins/spaces/common/space_attributes.test.js create mode 100644 x-pack/plugins/spaces/public/views/components/index.js create mode 100644 x-pack/plugins/spaces/public/views/components/space_avatar.js create mode 100644 x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/customize_space_avatar.test.js.snap create mode 100644 x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/url_context.test.js.snap create mode 100644 x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.js create mode 100644 x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.test.js create mode 100644 x-pack/plugins/spaces/public/views/management/edit_space/index.js rename x-pack/plugins/spaces/public/views/management/{components => edit_space}/manage_space_page.js (62%) rename x-pack/plugins/spaces/public/views/management/{components => edit_space}/reserved_space_badge.js (70%) rename x-pack/plugins/spaces/public/views/management/{components => edit_space}/reserved_space_badge.test.js (92%) rename x-pack/plugins/spaces/public/views/management/{components => edit_space}/url_context.js (94%) create mode 100644 x-pack/plugins/spaces/public/views/management/edit_space/url_context.test.js create mode 100644 x-pack/plugins/spaces/public/views/management/lib/index.js delete mode 100644 x-pack/plugins/spaces/public/views/management/lib/spaces_data_store.js delete mode 100644 x-pack/plugins/spaces/public/views/management/lib/spaces_data_store.test.js create mode 100644 x-pack/plugins/spaces/public/views/management/lib/validate_space.js create mode 100644 x-pack/plugins/spaces/public/views/management/lib/validate_space.test.js create mode 100644 x-pack/plugins/spaces/public/views/management/spaces_grid/index.js rename x-pack/plugins/spaces/public/views/management/{components => spaces_grid}/spaces_grid_page.js (50%) diff --git a/x-pack/plugins/spaces/common/constants.js b/x-pack/plugins/spaces/common/constants.js index 40e064ef983593..d539fafd71044b 100644 --- a/x-pack/plugins/spaces/common/constants.js +++ b/x-pack/plugins/spaces/common/constants.js @@ -5,3 +5,5 @@ */ export const DEFAULT_SPACE_ID = `default`; + +export const MAX_SPACE_INITIALS = 2; diff --git a/x-pack/plugins/spaces/common/index.js b/x-pack/plugins/spaces/common/index.js index 1cd6907d3d64c1..05320f110f4c76 100644 --- a/x-pack/plugins/spaces/common/index.js +++ b/x-pack/plugins/spaces/common/index.js @@ -4,4 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export { isReservedSpace } from './is_reserved_space'; \ No newline at end of file +export { isReservedSpace } from './is_reserved_space'; +export { MAX_SPACE_INITIALS } from './constants'; + +export { + getSpaceInitials, + getSpaceColor, +} from './space_attributes'; \ No newline at end of file diff --git a/x-pack/plugins/spaces/common/space_attributes.js b/x-pack/plugins/spaces/common/space_attributes.js new file mode 100644 index 00000000000000..2b0cee34023a3c --- /dev/null +++ b/x-pack/plugins/spaces/common/space_attributes.js @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { VISUALIZATION_COLORS } from '@elastic/eui'; +import { MAX_SPACE_INITIALS } from './constants'; + +/** + * Determines the color for the provided space. + * If a color is present on the Space itself, then that is used. + * Otherwise, a color is provided from EUI's Visualization Colors based on the space name. + * + * @param {Space} space + */ +export function getSpaceColor(space = {}) { + const { + color, + name = '', + } = space; + + if (color) { + return color; + } + + return VISUALIZATION_COLORS[name.codePointAt(0) % VISUALIZATION_COLORS.length]; +} + +/** + * Determines the initials for the provided space. + * If initials are present on the Space itself, then that is used. + * Otherwise, the initials are calculated based off the words in the space name, with a max length of 2 characters. + * + * @param {Space} space + */ +export function getSpaceInitials(space = {}) { + const { + initials, + name = '' + } = space; + + if (initials) { + return initials; + } + + const words = name.split(" "); + + const numInitials = Math.min(MAX_SPACE_INITIALS, words.length); + + words.splice(numInitials, words.length); + + return words.map(word => word.substring(0, 1)).join(''); +} diff --git a/x-pack/plugins/spaces/common/space_attributes.test.js b/x-pack/plugins/spaces/common/space_attributes.test.js new file mode 100644 index 00000000000000..8ed03ab21c4139 --- /dev/null +++ b/x-pack/plugins/spaces/common/space_attributes.test.js @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { getSpaceInitials, getSpaceColor } from "./space_attributes"; + +describe("getSpaceColor", () => { + test('uses color on the space, when provided', () => { + const space = { + name: 'Foo', + color: '#aabbcc' + }; + + expect(getSpaceColor(space)).toEqual('#aabbcc'); + }); + + test('derives color from space name if necessary', () => { + const space = { + name: 'Foo', + }; + + expect(getSpaceColor(space)).toMatch(/^#[a-f0-9]{6}$/i); + }); + + test('derives the same color for the same name', () => { + const space = { + name: 'FooBar', + }; + + const expectedColor = getSpaceColor(space); + + for (let i = 0; i < 100; i++) { + expect(getSpaceColor(space)).toEqual(expectedColor); + } + }); +}); + +describe("getSpaceInitials", () => { + test('uses initials on the space, when provided', () => { + const space = { + name: 'Foo', + initials: 'JK' + }; + + expect(getSpaceInitials(space)).toEqual('JK'); + }); + + test('derives initials from space name if necessary', () => { + const space = { + name: 'Foo', + }; + + expect(getSpaceInitials(space)).toEqual('F'); + }); + + test('uses words from the space name when deriving initials', () => { + const space = { + name: 'Foo Bar', + }; + + expect(getSpaceInitials(space)).toEqual('FB'); + }); + + test('only uses the first two words of the space name when deriving initials', () => { + const space = { + name: 'Very Special Name', + }; + + expect(getSpaceInitials(space)).toEqual('VS'); + }); + + test('maintains case when deriving initials', () => { + const space = { + name: 'some Space', + }; + + expect(getSpaceInitials(space)).toEqual('sS'); + }); +}); diff --git a/x-pack/plugins/spaces/mappings.json b/x-pack/plugins/spaces/mappings.json index 0f7ea917e12ef8..77e0c47c00ddd8 100644 --- a/x-pack/plugins/spaces/mappings.json +++ b/x-pack/plugins/spaces/mappings.json @@ -19,6 +19,12 @@ "description": { "type": "text" }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, "_reserved": { "type": "boolean" } diff --git a/x-pack/plugins/spaces/public/views/components/index.js b/x-pack/plugins/spaces/public/views/components/index.js new file mode 100644 index 00000000000000..81c7e9aea1c036 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/index.js @@ -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 { SpaceAvatar } from './space_avatar'; \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/components/space_avatar.js b/x-pack/plugins/spaces/public/views/components/space_avatar.js new file mode 100644 index 00000000000000..ccbb2996bad105 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/space_avatar.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiAvatar +} from '@elastic/eui'; +import { MAX_SPACE_INITIALS, getSpaceInitials, getSpaceColor } from '../../../common'; + +export const SpaceAvatar = (props) => { + return ( + + ); +}; diff --git a/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap b/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap index c7cbfdb53fd737..628a5c068f83a0 100644 --- a/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap +++ b/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap @@ -1,13 +1,15 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`it renders without crashing 1`] = ` - - - - - +
+ + +
`; diff --git a/x-pack/plugins/spaces/public/views/management/components/delete_spaces_button.js b/x-pack/plugins/spaces/public/views/management/components/delete_spaces_button.js index f1b403671fa955..bfd38f1749dd83 100644 --- a/x-pack/plugins/spaces/public/views/management/components/delete_spaces_button.js +++ b/x-pack/plugins/spaces/public/views/management/components/delete_spaces_button.js @@ -60,18 +60,11 @@ export class DeleteSpacesButton extends Component { deleteSpaces = () => { const { - httpAgent, - chrome, + spacesManager, spaces } = this.props; - console.log(this.props, spaces); - - const deleteOperations = spaces.map(space => { - return httpAgent.delete( - chrome.addBasePath(`/api/spaces/v1/spaces/${encodeURIComponent(space.id)}`) - ); - }); + const deleteOperations = spaces.map(space => spacesManager.deleteSpace(space)); Promise.all(deleteOperations) .then(() => { @@ -101,7 +94,6 @@ export class DeleteSpacesButton extends Component { DeleteSpacesButton.propTypes = { spaces: PropTypes.array.isRequired, - httpAgent: PropTypes.func.isRequired, - chrome: PropTypes.object.isRequired, + spacesManager: PropTypes.object.isRequired, onDelete: PropTypes.func }; diff --git a/x-pack/plugins/spaces/public/views/management/components/index.js b/x-pack/plugins/spaces/public/views/management/components/index.js index 5da70aad9e8e58..1d158d623b503a 100644 --- a/x-pack/plugins/spaces/public/views/management/components/index.js +++ b/x-pack/plugins/spaces/public/views/management/components/index.js @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SpacesGridPage } from './spaces_grid_page'; -export { ManageSpacePage } from './manage_space_page'; +export { PageHeader } from './page_header'; +export { DeleteSpacesButton } from './delete_spaces_button'; diff --git a/x-pack/plugins/spaces/public/views/management/components/page_header.js b/x-pack/plugins/spaces/public/views/management/components/page_header.js index 19a76b3d6ccf55..ff81942abb49ce 100644 --- a/x-pack/plugins/spaces/public/views/management/components/page_header.js +++ b/x-pack/plugins/spaces/public/views/management/components/page_header.js @@ -8,19 +8,17 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { - EuiHeader, - EuiHeaderSection, - EuiHeaderBreadcrumbs + EuiBreadcrumbs, + EuiSpacer, } from '@elastic/eui'; export class PageHeader extends Component { render() { return ( - - - - - +
+ + +
); } diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/customize_space_avatar.test.js.snap b/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/customize_space_avatar.test.js.snap new file mode 100644 index 00000000000000..cbc3efafff90e4 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/customize_space_avatar.test.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders without crashing 1`] = ` + + + + Customize + + + +`; diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/url_context.test.js.snap b/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/url_context.test.js.snap new file mode 100644 index 00000000000000..363f3951202c6d --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/url_context.test.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders without crashing 1`] = ` + + + Links within Kibana will include this space identifier +

+ } + isInvalid={false} + label={ +

+ URL Context + + [edit] + +

+ } + > +
+ +
+
+
+`; diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.js b/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.js new file mode 100644 index 00000000000000..7d6240d6a2c0a3 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.js @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { + EuiFlexItem, + EuiColorPicker, + EuiFormRow, + EuiFieldText, + EuiLink, +} from '@elastic/eui'; +import { getSpaceInitials, getSpaceColor } from '../../../../common/space_attributes'; +import { MAX_SPACE_INITIALS } from '../../../../common/constants'; + +export class CustomizeSpaceAvatar extends Component { + static propTypes = { + space: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + } + + state = { + expanded: false, + initialsHasFocus: false, + pendingInitials: null + } + + render() { + return this.state.expanded ? this.getCustomizeFields() : this.getCustomizeLink(); + } + + getCustomizeFields = () => { + const { + space + } = this.props; + + const { + initialsHasFocus, + pendingInitials, + } = this.state; + + return ( + + + + + + + + + + + + + ); + } + + initialsInputRef = (ref) => { + if (ref) { + this.initialsRef = ref; + this.initialsRef.addEventListener('focus', this.onInitialsFocus); + this.initialsRef.addEventListener('blur', this.onInitialsBlur); + } else { + if (this.initialsRef) { + this.initialsRef.removeEventListener('focus', this.onInitialsFocus); + this.initialsRef.removeEventListener('blur', this.onInitialsBlur); + this.initialsRef = null; + } + } + } + + onInitialsFocus = () => { + this.setState({ + initialsHasFocus: true, + pendingInitials: getSpaceInitials(this.props.space) + }); + } + + onInitialsBlur = () => { + this.setState({ + initialsHasFocus: false, + pendingInitials: null, + }); + } + + getCustomizeLink = () => { + return ( + + + Customize + + + ); + } + + showFields = () => { + this.setState({ + expanded: true + }); + } + + onInitialsChange = (e) => { + const initials = (e.target.value || '').substring(0, MAX_SPACE_INITIALS); + + this.setState({ + pendingInitials: initials, + }); + + this.props.onChange({ + ...this.props.space, + initials + }); + }; + + onColorChange = (color) => { + this.props.onChange({ + ...this.props.space, + color + }); + } +} diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.test.js b/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.test.js new file mode 100644 index 00000000000000..dfd1c85d19e3ea --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.test.js @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { CustomizeSpaceAvatar } from './customize_space_avatar'; +import { EuiLink, EuiFieldText, EuiColorPicker } from '@elastic/eui'; + +test('renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); + +test('renders a "customize" link by default', () => { + const wrapper = mount(); + expect(wrapper.find(EuiLink)).toHaveLength(1); +}); + +test('shows customization fields when the "customize" link is clicked', () => { + const wrapper = mount(); + wrapper.find(EuiLink).simulate('click'); + + expect(wrapper.find(EuiLink)).toHaveLength(0); + expect(wrapper.find(EuiFieldText)).toHaveLength(1); + expect(wrapper.find(EuiColorPicker)).toHaveLength(1); +}); + +test('invokes onChange callback when avatar is customized', () => { + const space = { + name: "Unit Test Space", + initials: "SP", + color: "#ABCDEF" + }; + + const changeHandler = jest.fn(); + + const wrapper = mount(); + wrapper.find(EuiLink).simulate('click'); + + wrapper.find(EuiFieldText).find('input').simulate('change', { target: { value: 'NV' } }); + + expect(changeHandler).toHaveBeenCalledWith({ + ...space, + initials: 'NV' + }); +}); \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/index.js b/x-pack/plugins/spaces/public/views/management/edit_space/index.js new file mode 100644 index 00000000000000..0d5429ca5ed55a --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/index.js @@ -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 { ManageSpacePage } from './manage_space_page'; \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/management/components/manage_space_page.js b/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js similarity index 62% rename from x-pack/plugins/spaces/public/views/management/components/manage_space_page.js rename to x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js index 2207076c3e695b..e1eb2de37e69fa 100644 --- a/x-pack/plugins/spaces/public/views/management/components/manage_space_page.js +++ b/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js @@ -4,36 +4,43 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { Fragment, Component } from 'react'; import PropTypes from 'prop-types'; import { - EuiText, + EuiTitle, + EuiButtonEmpty, EuiSpacer, EuiPage, - EuiPageContent, EuiForm, EuiFormRow, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiButton, + EuiPanel, } from '@elastic/eui'; -import { PageHeader } from './page_header'; -import { DeleteSpacesButton } from './delete_spaces_button'; +import { DeleteSpacesButton, PageHeader } from '../components'; +import { SpaceAvatar } from '../../components'; import { Notifier, toastNotifications } from 'ui/notify'; import { UrlContext } from './url_context'; import { toUrlContext, isValidUrlContext } from '../lib/url_context_utils'; +import { CustomizeSpaceAvatar } from './customize_space_avatar'; import { isReservedSpace } from '../../../../common'; import { ReservedSpaceBadge } from './reserved_space_badge'; +import { SpaceValidator } from '../lib/validate_space'; -export class ManageSpacePage extends React.Component { +export class ManageSpacePage extends Component { state = { space: {}, - validate: false }; + constructor(props) { + super(props); + this.validator = new SpaceValidator({ shouldValidate: false }); + } + componentDidMount() { this.notifier = new Notifier({ location: 'Spaces' }); @@ -65,34 +72,68 @@ export class ManageSpacePage extends React.Component { render() { const { name = '', - description = '' + description = '', } = this.state.space; return ( - - - - - {this.getFormHeading()} + + + + {this.getFormHeading()} + + + + + + + + + + + { + name && ( + + + + + + + + + + + ) + } + - - - + {isReservedSpace(this.state.space) + ? null + : ( + + + + ) + } + - + - - - Save - - - - Cancel - - - - - + + + {this.getFormButtons()} + + ); } getFormHeading = () => { - const isReserved = isReservedSpace(this.state.space); - return ( - - -

{this.getTitle()}

-
- {isReserved ? this.getReservedBadge() : this.getActionButton()} -
+

{this.getTitle()}

); }; @@ -142,10 +164,27 @@ export class ManageSpacePage extends React.Component { if (this.editingExistingSpace()) { return `Edit space`; } - return `Create a space`; + return `Create space`; }; - getReservedBadge = () => ; + getFormButtons = () => { + const saveText = this.editingExistingSpace() ? 'Update space' : 'Create space'; + return ( + + + {saveText} + + + + Cancel + + + + {this.getActionButton()} + + ); + } + getActionButton = () => { if (this.editingExistingSpace() && !isReservedSpace(this.state.space)) { @@ -153,8 +192,7 @@ export class ManageSpacePage extends React.Component { @@ -202,14 +240,25 @@ export class ManageSpacePage extends React.Component { }); }; - saveSpace = () => { + onAvatarChange = (space) => { this.setState({ - validate: true - }, () => { - const { isInvalid } = this.validateForm(); - if (isInvalid) return; - this._performSave(); + space }); + } + + saveSpace = () => { + this.validator.enableValidation(); + + const result = this.validator.validateForSave(this.state.space); + if (result.isInvalid) { + this.setState({ + formError: result + }); + + return; + } + + this._performSave(); }; _performSave = () => { @@ -217,6 +266,8 @@ export class ManageSpacePage extends React.Component { name = '', id = toUrlContext(name), description, + initials, + color, urlContext } = this.state.space; @@ -224,6 +275,8 @@ export class ManageSpacePage extends React.Component { name, id, description, + initials, + color, urlContext }; @@ -340,7 +393,5 @@ export class ManageSpacePage extends React.Component { ManageSpacePage.propTypes = { space: PropTypes.string, spacesManager: PropTypes.object, - httpAgent: PropTypes.func.isRequired, - chrome: PropTypes.object, breadcrumbs: PropTypes.array.isRequired, }; diff --git a/x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.js b/x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.js similarity index 70% rename from x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.js rename to x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.js index 0ea7e05d77e12a..2f0f96377fcb4d 100644 --- a/x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.js +++ b/x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.js @@ -9,9 +9,8 @@ import PropTypes from 'prop-types'; import { isReservedSpace } from '../../../../common'; import { - EuiBadge, + EuiIcon, EuiToolTip, - EuiFlexItem, } from '@elastic/eui'; @@ -22,11 +21,9 @@ export const ReservedSpaceBadge = (props) => { if (isReservedSpace(space)) { return ( - - - Reserved Space - - + + + ); } return null; diff --git a/x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.test.js b/x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.test.js similarity index 92% rename from x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.test.js rename to x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.test.js index e45926b8598246..755e6e4bf011e0 100644 --- a/x-pack/plugins/spaces/public/views/management/components/reserved_space_badge.test.js +++ b/x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.test.js @@ -6,7 +6,7 @@ import React from 'react'; import { - EuiBadge + EuiIcon } from '@elastic/eui'; import { ReservedSpaceBadge } from './reserved_space_badge'; import { @@ -21,7 +21,7 @@ const unreservedSpace = {}; test('it renders without crashing', () => { const wrapper = shallow(); - expect(wrapper.find(EuiBadge)).toHaveLength(1); + expect(wrapper.find(EuiIcon)).toHaveLength(1); }); test('it renders nothing for an unreserved space', () => { diff --git a/x-pack/plugins/spaces/public/views/management/components/url_context.js b/x-pack/plugins/spaces/public/views/management/edit_space/url_context.js similarity index 94% rename from x-pack/plugins/spaces/public/views/management/components/url_context.js rename to x-pack/plugins/spaces/public/views/management/edit_space/url_context.js index 6253e36d0f9e2e..853c0abde745e5 100644 --- a/x-pack/plugins/spaces/public/views/management/components/url_context.js +++ b/x-pack/plugins/spaces/public/views/management/edit_space/url_context.js @@ -31,6 +31,7 @@ export class UrlContext extends Component {
{ + const props = { + space: {}, + editable: true, + editingExistingSpace: false, + onChange: jest.fn(), + validator: new SpaceValidator() + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/management/lib/index.js b/x-pack/plugins/spaces/public/views/management/lib/index.js new file mode 100644 index 00000000000000..0d213ec10b93c1 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/lib/index.js @@ -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. + */ + +export { + toUrlContext, + isValidUrlContext +} from './url_context_utils'; + +export { + SpaceValidator +} from './validate_space'; \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/management/lib/spaces_data_store.js b/x-pack/plugins/spaces/public/views/management/lib/spaces_data_store.js deleted file mode 100644 index 64e7f6c5fa6354..00000000000000 --- a/x-pack/plugins/spaces/public/views/management/lib/spaces_data_store.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export class SpacesDataStore { - constructor(spaces = []) { - this.loadSpaces(spaces); - } - - loadSpaces(spaces = []) { - this._spaces = [...spaces]; - this._matchedSpaces = [...spaces]; - } - - search(searchCriteria, caseSensitive = false) { - const criteria = caseSensitive ? searchCriteria : searchCriteria.toLowerCase(); - - this._matchedSpaces = this._spaces.filter(space => { - const spaceName = caseSensitive ? space.name : space.name.toLowerCase(); - - return spaceName.indexOf(criteria) >= 0; - }); - - return this._matchedSpaces; - } - - getPage(pageIndex, pageSize) { - const startIndex = Math.min(pageIndex * pageSize, this._matchedSpaces.length); - const endIndex = Math.min(startIndex + pageSize, this._matchedSpaces.length); - - return this._matchedSpaces.slice(startIndex, endIndex); - } -} diff --git a/x-pack/plugins/spaces/public/views/management/lib/spaces_data_store.test.js b/x-pack/plugins/spaces/public/views/management/lib/spaces_data_store.test.js deleted file mode 100644 index eac71d490902d2..00000000000000 --- a/x-pack/plugins/spaces/public/views/management/lib/spaces_data_store.test.js +++ /dev/null @@ -1,44 +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 { SpacesDataStore } from './spaces_data_store'; - -const spaces = [{ - id: 1, - name: 'foo', - description: 'foo' -}, { - id: 2, - name: 'bar', - description: 'bar' -}, { - id: 3, - name: 'sample text', - description: 'some sample text' -}]; - -test(`it doesn't filter results with no search applied`, () => { - const store = new SpacesDataStore(spaces); - expect(store.getPage(0, 3)).toEqual(spaces); -}); - -test(`it filters results when search is applied`, () => { - const store = new SpacesDataStore(spaces); - const filteredResults = store.search('bar'); - - expect(filteredResults).toEqual([spaces[1]]); - - expect(store.getPage(0, 3)).toEqual([spaces[1]]); -}); - -test(`it filters based on a partial match`, () => { - const store = new SpacesDataStore(spaces); - const filteredResults = store.search('mpl'); - - expect(filteredResults).toEqual([spaces[2]]); - - expect(store.getPage(0, 3)).toEqual([spaces[2]]); -}); diff --git a/x-pack/plugins/spaces/public/views/management/lib/validate_space.js b/x-pack/plugins/spaces/public/views/management/lib/validate_space.js new file mode 100644 index 00000000000000..a59a5ee0ef6f81 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/lib/validate_space.js @@ -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 { isValidUrlContext } from './url_context_utils'; +import { isReservedSpace } from '../../../../common/is_reserved_space'; + +export class SpaceValidator { + constructor(options = {}) { + this._shouldValidate = options.shouldValidate; + } + + enableValidation() { + this._shouldValidate = true; + } + + disableValidation() { + this._shouldValidate = false; + } + + validateSpaceName(space) { + if (!this._shouldValidate) return valid(); + + if (!space.name) { + return invalid(`Please provide a space name`); + } + + if (space.name.length > 1024) { + return invalid(`Name must not exceed 1024 characters`); + } + + return valid(); + } + + validateSpaceDescription(space) { + if (!this._shouldValidate) return valid(); + + if (!space.description) { + return invalid(`Please provide a space description`); + } + + return valid(); + } + + validateUrlContext(space) { + if (!this._shouldValidate) return valid(); + + if (isReservedSpace(space)) return valid(); + + if (!space.urlContext) { + return invalid(`URL Context is required`); + } + + if (!isValidUrlContext(space.urlContext)) { + return invalid('URL Context only allows a-z, 0-9, and the "-" character'); + } + + return valid(); + } + + validateForSave(space) { + const { isInvalid: isNameInvalid } = this.validateSpaceName(space); + const { isInvalid: isDescriptionInvalid } = this.validateSpaceDescription(space); + const { isInvalid: isContextInvalid } = this.validateUrlContext(space); + + if (isNameInvalid || isDescriptionInvalid || isContextInvalid) { + return invalid(); + } + + return valid(); + } +} + +function invalid(error) { + return { + isInvalid: true, + error + }; +} + +function valid() { + return { + isInvalid: false + }; +} diff --git a/x-pack/plugins/spaces/public/views/management/lib/validate_space.test.js b/x-pack/plugins/spaces/public/views/management/lib/validate_space.test.js new file mode 100644 index 00000000000000..6c0f2c2bcc10e1 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/lib/validate_space.test.js @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { SpaceValidator } from './validate_space'; + +let validator; + +describe('validateSpaceName', () => { + beforeEach(() => { + validator = new SpaceValidator({ shouldValidate: true }); + }); + + test('it allows a name with special characters', () => { + const space = { + name: 'This is the name of my Space! @#$%^&*()_+-=' + }; + + expect(validator.validateSpaceName(space)).toEqual({ isInvalid: false }); + }); + + test('it requires a non-empty value', () => { + const space = { + name: '' + }; + + expect(validator.validateSpaceName(space)).toEqual({ isInvalid: true, error: `Please provide a space name` }); + }); + + test('it cannot exceed 1024 characters', () => { + const space = { + name: new Array(1026).join('A') + }; + + expect(validator.validateSpaceName(space)).toEqual({ isInvalid: true, error: `Name must not exceed 1024 characters` }); + }); +}); + +describe('validateSpaceDescription', () => { + test('it requires a non-empty value', () => { + const space = { + description: '' + }; + + expect(validator.validateSpaceDescription(space)).toEqual({ isInvalid: true, error: `Please provide a space description` }); + }); +}); + +describe('validateUrlContext', () => { + test('it does not validate reserved spaces', () => { + const space = { + _reserved: true + }; + + expect(validator.validateUrlContext(space)).toEqual({ isInvalid: false }); + }); + + test('it requires a non-empty value', () => { + const space = { + urlContext: '' + }; + + expect(validator.validateUrlContext(space)).toEqual({ isInvalid: true, error: `URL Context is required` }); + }); + + test('it requires a valid URL Context', () => { + const space = { + urlContext: 'invalid context' + }; + + expect(validator.validateUrlContext(space)) + .toEqual({ isInvalid: true, error: 'URL Context only allows a-z, 0-9, and the "-" character' }); + }); + + test('it allows a valid URL Context', () => { + const space = { + urlContext: '01-valid-context-01' + }; + + expect(validator.validateUrlContext(space)).toEqual({ isInvalid: false }); + }); +}); \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/management/manage_spaces.less b/x-pack/plugins/spaces/public/views/management/manage_spaces.less index ae515cbb829fa1..48bfea9b424d95 100644 --- a/x-pack/plugins/spaces/public/views/management/manage_spaces.less +++ b/x-pack/plugins/spaces/public/views/management/manage_spaces.less @@ -1,5 +1,14 @@ -.manageSpaces__application, .manageSpaces__.euiPanel { - background: #f5f5f5 +.manageSpaces__application, .manageSpaces__.euiPanel, #manageSpacesReactRoot, .editSpacePage, .editSpacePage__content { + background: #f5f5f5; +} + +#manageSpacesReactRoot{ + flex-grow: 1; +} + +.editSpacePage__content { + border: none; + box-shadow: none; } .manageSpaces__euiPage { diff --git a/x-pack/plugins/spaces/public/views/management/page_routes.js b/x-pack/plugins/spaces/public/views/management/page_routes.js index e07a5b95aeb239..e037ec46923c8f 100644 --- a/x-pack/plugins/spaces/public/views/management/page_routes.js +++ b/x-pack/plugins/spaces/public/views/management/page_routes.js @@ -10,7 +10,8 @@ import template from 'plugins/spaces/views/management/template.html'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { SpacesGridPage, ManageSpacePage } from './components'; +import { SpacesGridPage } from './spaces_grid'; +import { ManageSpacePage } from './edit_space'; import { SpacesManager } from '../../lib/spaces_manager'; import routes from 'ui/routes'; @@ -22,10 +23,11 @@ routes.when('/management/spaces/list', { controller: function ($scope, $http, chrome) { const domNode = document.getElementById(reactRootNodeId); + const spacesManager = new SpacesManager($http, chrome); + render(, domNode); // unmount react on controller destroy @@ -43,8 +45,6 @@ routes.when('/management/spaces/create', { const spacesManager = new SpacesManager($http, chrome); render(, domNode); @@ -73,7 +73,7 @@ routes.when('/management/spaces/edit/:space', { httpAgent={$http} space={space} chrome={chrome} - breadcrumbs={routes.getBreadcrumbs()} + breadcrumbs={transformBreadcrumbs(routes.getBreadcrumbs())} spacesManager={spacesManager} />, domNode); @@ -83,3 +83,7 @@ routes.when('/management/spaces/edit/:space', { }); } }); + +function transformBreadcrumbs(routeBreadcrumbs) { + return routeBreadcrumbs.filter(b => b.id !== 'edit'); +} \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/management/spaces_grid/index.js b/x-pack/plugins/spaces/public/views/management/spaces_grid/index.js new file mode 100644 index 00000000000000..ec688bbf260a60 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/spaces_grid/index.js @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { SpacesGridPage } from './spaces_grid_page'; \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/management/components/spaces_grid_page.js b/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.js similarity index 50% rename from x-pack/plugins/spaces/public/views/management/components/spaces_grid_page.js rename to x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.js index c89ae778afbc5b..a039864aff3949 100644 --- a/x-pack/plugins/spaces/public/views/management/components/spaces_grid_page.js +++ b/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.js @@ -10,8 +10,7 @@ import PropTypes from 'prop-types'; import { EuiPage, EuiPageContent, - EuiBasicTable, - EuiSearchBar, + EuiInMemoryTable, EuiFlexGroup, EuiFlexItem, EuiText, @@ -20,45 +19,24 @@ import { EuiLink, } from '@elastic/eui'; -import { PageHeader } from './page_header'; -import { SpacesDataStore } from '../lib/spaces_data_store'; -import { DeleteSpacesButton } from './delete_spaces_button'; - +import { DeleteSpacesButton, PageHeader } from '../components'; +import { isReservedSpace } from '../../../../common'; export class SpacesGridPage extends Component { state = { selectedSpaces: [], - displayedSpaces: [], - loading: true, - searchCriteria: '', - pagination: { - pageIndex: 0, - pageSize: 10, - totalItemCount: 0, - pageSizeOptions: [10, 25, 50] - } + spaces: [], + loading: true }; - constructor(props) { - super(props); - this.dataStore = new SpacesDataStore(); - } - componentDidMount() { this.loadGrid(); } render() { - const filteredSpaces = this.dataStore.search(this.state.searchCriteria); - - const pagination = { - ...this.state.pagination, - totalItemCount: filteredSpaces.length - }; - return ( - + @@ -67,20 +45,19 @@ export class SpacesGridPage extends Component { {this.getPrimaryActionButton()} - - - !isReservedSpace(space), + onSelectionChange: this.onSelectionChange + }} + pagination={true} + search={true} + loading={this.state.loading} + message={this.state.loading ? "loading..." : undefined} /> @@ -92,8 +69,7 @@ export class SpacesGridPage extends Component { return ( ); @@ -106,30 +82,25 @@ export class SpacesGridPage extends Component { loadGrid = () => { const { - httpAgent, - chrome + spacesManager } = this.props; this.setState({ loading: true, - displayedSpaces: [], - selectedSpaces: [] + selectedSpaces: [], + spaces: [] }); - this.dataStore.loadSpaces([]); - const setSpaces = (spaces) => { - this.dataStore.loadSpaces(spaces); this.setState({ loading: false, - displayedSpaces: this.dataStore.getPage(this.state.pagination.pageIndex, this.state.pagination.pageSize) + spaces, }); }; - httpAgent - .get(chrome.addBasePath(`/api/spaces/v1/spaces`)) - .then(response => { - setSpaces(response.data); + spacesManager.getSpaces() + .then(spaces => { + setSpaces(spaces); }) .catch(error => { this.setState({ @@ -162,44 +133,12 @@ export class SpacesGridPage extends Component { }]; } - getSelectionConfig() { - return { - itemId: 'id', - selectable: (space) => space.id, - onSelectionChange: this.onSelectionChange - }; - } - - onTableChange = ({ page = {} }) => { - const { - index: pageIndex, - size: pageSize - } = page; - - this.setState({ - pagination: { - ...this.state.pagination, - pageIndex, - pageSize - } - }); - }; - onSelectionChange = (selectedSpaces) => { this.setState({ selectedSpaces }); }; - - onSearchChange = ({ text = '' }) => { - this.dataStore.search(text); - this.setState({ - searchCriteria: text, - displayedSpaces: this.dataStore.getPage(this.state.pagination.pageIndex, this.state.pagination.pageSize) - }); - }; } SpacesGridPage.propTypes = { - chrome: PropTypes.object.isRequired, - httpAgent: PropTypes.func.isRequired, - breadcrumbs: PropTypes.array.isRequired + spacesManager: PropTypes.object.isRequired, + breadcrumbs: PropTypes.array.isRequired, }; diff --git a/x-pack/plugins/spaces/server/lib/space_schema.js b/x-pack/plugins/spaces/server/lib/space_schema.js index 88c134ca6f82b9..82dd81ee1705ec 100644 --- a/x-pack/plugins/spaces/server/lib/space_schema.js +++ b/x-pack/plugins/spaces/server/lib/space_schema.js @@ -5,11 +5,14 @@ */ import Joi from 'joi'; +import { MAX_SPACE_INITIALS } from '../../common/constants'; export const spaceSchema = Joi.object({ id: Joi.string(), name: Joi.string().required(), description: Joi.string().required(), - urlContext: Joi.string().regex(/[a-z0-9\-]+/, `lower case, a-z, 0-9, and "-" are allowed`).required(), + urlContext: Joi.string().regex(/[a-z0-9\-]*/, `lower case, a-z, 0-9, and "-" are allowed`), + initials: Joi.string().max(MAX_SPACE_INITIALS), + color: Joi.string().regex(/^#[a-z0-9]{6}$/, `6 digit hex color, starting with a #`), _reserved: Joi.boolean() }).default(); diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js index 64b893b93239d5..653b566a012e39 100644 --- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js @@ -63,7 +63,7 @@ export function initSpacesApi(server) { }); spaces = result.saved_objects.map(convertSavedObjectToSpace); - } catch(e) { + } catch (e) { return reply(wrapError(e)); } @@ -131,6 +131,12 @@ export function initSpacesApi(server) { } return reply(convertSavedObjectToSpace(result)); + }, + config: { + validate: { + payload: spaceSchema + }, + pre: [routePreCheckLicenseFn] } }); @@ -156,7 +162,7 @@ export function initSpacesApi(server) { let result; try { result = await client.create('space', { ...space }, { id, overwrite }); - } catch(e) { + } catch (e) { return reply(wrapError(e)); } From a53e7d06caeefe88f53fd2df0cb243cedf46aa26 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 19 Jun 2018 11:43:06 -0400 Subject: [PATCH 053/183] start to address feedback --- .../public/services/application_privilege.js | 3 --- .../security/public/views/management/edit_role.js | 8 +++++--- .../server/lib/authorization/has_privileges.js | 4 ++-- .../lib/privileges/privilege_action_registry.js | 3 --- .../security/server/lib/privileges/privileges.js | 3 --- .../watch_status_and_license_to_initialize.test.js | 13 +++++-------- .../security/server/routes/api/v1/privileges.js | 3 --- 7 files changed, 12 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/security/public/services/application_privilege.js b/x-pack/plugins/security/public/services/application_privilege.js index 57d2871ecbad39..91e8d52a84a726 100644 --- a/x-pack/plugins/security/public/services/application_privilege.js +++ b/x-pack/plugins/security/public/services/application_privilege.js @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 'angular-resource'; import { uiModules } from 'ui/modules'; diff --git a/x-pack/plugins/security/public/views/management/edit_role.js b/x-pack/plugins/security/public/views/management/edit_role.js index 0aaebc83e6f72f..94e04a30a37db5 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.js +++ b/x-pack/plugins/security/public/views/management/edit_role.js @@ -5,6 +5,7 @@ */ import _ from 'lodash'; +import chrome from 'ui/chrome'; import routes from 'ui/routes'; import { fatalError, toastNotifications } from 'ui/notify'; import { toggle } from 'plugins/security/lib/util'; @@ -38,7 +39,7 @@ const getKibanaPrivileges = (kibanaApplicationPrivilege, role, application) => { const applications = role.applications .filter(x => x.application === application && x.resources.every(r => r === DEFAULT_RESOURCE)); - const assigned = _.uniq(_.flatten(_.pluck(applications, 'privileges'))); + const assigned = _.uniq(_.flatten(_.pluck(applications, 'privileges'))); assigned.forEach(a => { kibanaPrivileges[a] = true; }); @@ -63,7 +64,7 @@ const setApplicationPrivileges = (kibanaPrivileges, role, application) => { role.applications = [...role.applications, { application, privileges, - resources: [ DEFAULT_RESOURCE ] + resources: [DEFAULT_RESOURCE] }]; } }; @@ -118,7 +119,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { } }, controllerAs: 'editRole', - controller($injector, $scope, rbacApplication) { + controller($injector, $scope) { const $route = $injector.get('$route'); const kbnUrl = $injector.get('kbnUrl'); const shieldPrivileges = $injector.get('shieldPrivileges'); @@ -126,6 +127,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { const Private = $injector.get('Private'); const confirmModal = $injector.get('confirmModal'); const shieldIndices = $injector.get('shieldIndices'); + const rbacApplication = chrome.getInjected('rbacApplication'); $scope.role = $route.current.locals.role; $scope.users = $route.current.locals.users; diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.js index 79907d4e7b1e4e..67de50730959c0 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.js @@ -48,7 +48,7 @@ const hasLegacyPrivileges = async ( const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { body: { index: [{ - names: [ kibanaIndex ], + names: [kibanaIndex], privileges: ['read', 'index'] }] } @@ -63,7 +63,7 @@ const hasLegacyPrivileges = async ( const logDeprecation = () => { deprecationLogger( - `Relying on implicit privileges determined from the index privileges is deprecated and will be removed in the next major version` + `Relying on implicit privileges determined from the index privileges is deprecated and will be removed in Kibana 7.0` ); }; diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js index 27a00a56202132..822447dbcecedd 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { buildPrivilegeMap } from './privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; import { equivalentPrivileges } from './equivalent_privileges'; diff --git a/x-pack/plugins/security/server/lib/privileges/privileges.js b/x-pack/plugins/security/server/lib/privileges/privileges.js index 4045b3e83da036..96600013887944 100644 --- a/x-pack/plugins/security/server/lib/privileges/privileges.js +++ b/x-pack/plugins/security/server/lib/privileges/privileges.js @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 getVersionPrivilege(kibanaVersion) { // TODO: Remove the .toLowerCase() once the capitalization with app privileges is fixed return `version:${kibanaVersion.toLowerCase()}`; diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js index 3826edf6767617..55c2d83ab48f60 100644 --- a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js +++ b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { EventEmitter } from 'events'; import { watchStatusAndLicenseToInitialize } from './watch_status_and_license_to_initialize'; @@ -29,7 +27,7 @@ const createMockXpackMainPluginAndFeature = (featureId) => { } }; - const mockXpackMainPlugin = { + const mockXpackMainPlugin = { info: { feature: (id) => { if (id === featureId) { @@ -64,7 +62,7 @@ const createMockDownstreamPlugin = (id) => { }; }; -['red', 'yellow', 'disabled' ].forEach(state => { +['red', 'yellow', 'disabled'].forEach(state => { test(`mirrors ${state} immediately`, () => { const pluginId = 'foo-plugin'; const message = `${state} is now the state`; @@ -72,7 +70,7 @@ const createMockDownstreamPlugin = (id) => { mockXpackMainPlugin.mock.setStatus(state, message); const downstreamPlugin = createMockDownstreamPlugin(pluginId); const initializeMock = jest.fn(); - downstreamPlugin.status[state].mockImplementation(() => {}); + downstreamPlugin.status[state].mockImplementation(() => { }); watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); @@ -89,7 +87,7 @@ test(`calls initialize and doesn't immediately set downstream status when the in const licenseCheckResults = Symbol(); mockFeature.mock.setLicenseCheckResults(licenseCheckResults); const downstreamPlugin = createMockDownstreamPlugin(pluginId); - const initializeMock = jest.fn().mockImplementation(() => new Promise(() => {})); + const initializeMock = jest.fn().mockImplementation(() => new Promise(() => { })); watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); @@ -111,8 +109,7 @@ test(`sets downstream plugin's status to green when initialize resolves`, (done) expect(initializeMock).toHaveBeenCalledTimes(1); expect(initializeMock).toHaveBeenCalledWith(licenseCheckResults); - downstreamPlugin.status.green.mockImplementation(actualMessage => - { + downstreamPlugin.status.green.mockImplementation(actualMessage => { expect(actualMessage).toBe('Ready'); done(); }); diff --git a/x-pack/plugins/security/server/routes/api/v1/privileges.js b/x-pack/plugins/security/server/routes/api/v1/privileges.js index 54ce1f97110355..273994b25e562b 100644 --- a/x-pack/plugins/security/server/routes/api/v1/privileges.js +++ b/x-pack/plugins/security/server/routes/api/v1/privileges.js @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { buildPrivilegeMap } from '../../../lib/privileges/privileges'; export function initPrivilegesApi(server) { From b7d2514c15bdcbeb6c8e6d56354fa47100188214 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 19 Jun 2018 17:03:10 -0400 Subject: [PATCH 054/183] fix merge --- .../public/views/management/edit_role/index.js | 6 ++++-- yarn.lock | 10 ---------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/security/public/views/management/edit_role/index.js b/x-pack/plugins/security/public/views/management/edit_role/index.js index e356b9d7c01b6e..8f373f966f2a14 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/index.js +++ b/x-pack/plugins/security/public/views/management/edit_role/index.js @@ -5,6 +5,7 @@ */ import _ from 'lodash'; +import chrome from 'ui/chrome'; import routes from 'ui/routes'; import { fatalError } from 'ui/notify'; import template from 'plugins/security/views/management/edit_role/edit_role.html'; @@ -76,7 +77,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { } }, controllerAs: 'editRole', - controller($injector, $scope, $http, rbacEnabled, rbacApplication) { + controller($injector, $scope, $http) { const $route = $injector.get('$route'); const Private = $injector.get('Private'); @@ -88,6 +89,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { const xpackInfo = Private(XPackInfoProvider); const allowDocumentLevelSecurity = xpackInfo.get('features.security.allowRoleDocumentLevelSecurity'); const allowFieldLevelSecurity = xpackInfo.get('features.security.allowRoleFieldLevelSecurity'); + const rbacApplication = chrome.getInjected('rbacApplication'); const domNode = document.getElementById('editRoleReactRoot'); @@ -103,7 +105,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { role={role} kibanaAppPrivileges={kibanaApplicationPrivilege} indexPatterns={indexPatterns} - rbacEnabled={rbacEnabled} + rbacEnabled={true} rbacApplication={rbacApplication} httpClient={$http} breadcrumbs={transformBreadcrumbs(routeBreadcrumbs)} diff --git a/yarn.lock b/yarn.lock index a1bcbf1ac7e7fa..71756fd6c6e2ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1900,12 +1900,6 @@ brace-expansion@^1.0.0, brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -brace@0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/brace/-/brace-0.10.0.tgz#edef4eb9b0928ba1ee5f717ffc157749a6dd5d76" - dependencies: - w3c-blob "0.0.1" - brace@0.11.1, brace@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/brace/-/brace-0.11.1.tgz#4896fcc9d544eef45f4bb7660db320d3b379fe58" @@ -13446,10 +13440,6 @@ void-elements@^2.0.0, void-elements@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" -w3c-blob@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/w3c-blob/-/w3c-blob-0.0.1.tgz#b0cd352a1a50f515563420ffd5861f950f1d85b8" - w3c-hr-time@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045" From 1bc85f98636cde05c6d67395197c00a82fc7c7f2 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 20 Jun 2018 17:39:48 -0400 Subject: [PATCH 055/183] [Spaces] - Redesign space selector (#19964) --- x-pack/plugins/spaces/common/constants.js | 8 + .../spaces/public/views/components/index.js | 3 +- .../public/views/components/space_avatar.js | 17 +- .../public/views/components/space_card.js | 25 ++- .../public/views/components/space_card.less | 11 +- .../public/views/components/space_cards.js | 26 +-- .../public/views/components/space_cards.less | 4 + .../views/components/space_cards.test.js | 31 +-- .../views/nav_control/nav_control_modal.js | 198 +++++++++--------- .../__snapshots__/space_selector.test.js.snap | 125 +++++++---- .../views/space_selector/space_selector.html | 2 +- .../views/space_selector/space_selector.js | 83 ++++++-- .../views/space_selector/space_selector.less | 50 +++-- .../spaces/server/routes/api/v1/spaces.js | 1 - 14 files changed, 337 insertions(+), 247 deletions(-) create mode 100644 x-pack/plugins/spaces/public/views/components/space_cards.less diff --git a/x-pack/plugins/spaces/common/constants.js b/x-pack/plugins/spaces/common/constants.js index d539fafd71044b..c4d8931871b97d 100644 --- a/x-pack/plugins/spaces/common/constants.js +++ b/x-pack/plugins/spaces/common/constants.js @@ -6,4 +6,12 @@ export const DEFAULT_SPACE_ID = `default`; +/** + * The minimum number of spaces required to show a search control. + */ +export const SPACE_SEARCH_COUNT_THRESHOLD = 8; + +/** + * The maximum number of characters allowed in the Space Avatar's initials + */ export const MAX_SPACE_INITIALS = 2; diff --git a/x-pack/plugins/spaces/public/views/components/index.js b/x-pack/plugins/spaces/public/views/components/index.js index 81c7e9aea1c036..16d03e0d129bc1 100644 --- a/x-pack/plugins/spaces/public/views/components/index.js +++ b/x-pack/plugins/spaces/public/views/components/index.js @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SpaceAvatar } from './space_avatar'; \ No newline at end of file +export { SpaceAvatar } from './space_avatar'; +export { SpaceCards } from './space_cards'; \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/components/space_avatar.js b/x-pack/plugins/spaces/public/views/components/space_avatar.js index ccbb2996bad105..b7908d00f2dde8 100644 --- a/x-pack/plugins/spaces/public/views/components/space_avatar.js +++ b/x-pack/plugins/spaces/public/views/components/space_avatar.js @@ -5,20 +5,27 @@ */ import React from 'react'; +import PropTypes from 'prop-types'; import { EuiAvatar } from '@elastic/eui'; import { MAX_SPACE_INITIALS, getSpaceInitials, getSpaceColor } from '../../../common'; -export const SpaceAvatar = (props) => { +export const SpaceAvatar = ({ space, size, ...rest }) => { return ( ); }; + +SpaceAvatar.propTypes = { + space: PropTypes.object.isRequired, + size: PropTypes.string, +}; diff --git a/x-pack/plugins/spaces/public/views/components/space_card.js b/x-pack/plugins/spaces/public/views/components/space_card.js index 12cc3555175559..7441366d9206ed 100644 --- a/x-pack/plugins/spaces/public/views/components/space_card.js +++ b/x-pack/plugins/spaces/public/views/components/space_card.js @@ -7,9 +7,12 @@ import React from 'react'; import { EuiCard, - EuiText + EuiTextColor, } from '@elastic/eui'; import './space_card.less'; +import { + SpaceAvatar +} from './space_avatar'; export const SpaceCard = (props) => { const { @@ -20,21 +23,25 @@ export const SpaceCard = (props) => { return ( ); }; -function renderSpaceTitle(space) { - return ( -
-

{space.name}

-
- ); +function renderSpaceAvatar(space) { + return ; } function renderSpaceDescription(space) { - return space.description; + let description = space.description; + const needsTruncation = space.description.length > 120; + if (needsTruncation) { + description = ( + {space.description.substr(0, 120) + '…'} + ); + } + return {description}; } diff --git a/x-pack/plugins/spaces/public/views/components/space_card.less b/x-pack/plugins/spaces/public/views/components/space_card.less index a75fbfaa2f3430..8245a16b9f43c6 100644 --- a/x-pack/plugins/spaces/public/views/components/space_card.less +++ b/x-pack/plugins/spaces/public/views/components/space_card.less @@ -1,9 +1,8 @@ -.spaceCard, .euiCard.euiCard--isClickable.spaceCard { - width: 310px; - height: 230px; - min-height: 200px; +.euiCard.euiCard--isClickable.spaceCard { + width: 240px; + min-height: 200px; } -.spaceCardDescription { - margin-bottom: 10px; +.spaceCard .euiCard__content{ + overflow: hidden; } diff --git a/x-pack/plugins/spaces/public/views/components/space_cards.js b/x-pack/plugins/spaces/public/views/components/space_cards.js index 2e92ce4a290eab..5e96bc50cc4e20 100644 --- a/x-pack/plugins/spaces/public/views/components/space_cards.js +++ b/x-pack/plugins/spaces/public/views/components/space_cards.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import PropTypes from 'prop-types'; import chrome from 'ui/chrome'; import { SpaceCard } from './space_card'; @@ -12,29 +12,17 @@ import { stripSpaceUrlContext } from '../../../common/spaces_url_parser'; import { EuiFlexGroup, EuiFlexItem, - EuiSpacer, } from '@elastic/eui'; -import { chunk } from 'lodash'; +import './space_cards.less'; export class SpaceCards extends Component { render() { - const maxSpacesPerRow = 3; - const rows = chunk(this.props.spaces, maxSpacesPerRow); - return ( - - { - rows.map((row, idx) => ( - - - {row.map(this.renderSpace)} - - - - )) - } - - +
+ + {this.props.spaces.map(this.renderSpace)} + +
); } diff --git a/x-pack/plugins/spaces/public/views/components/space_cards.less b/x-pack/plugins/spaces/public/views/components/space_cards.less new file mode 100644 index 00000000000000..8108285e3dce76 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/space_cards.less @@ -0,0 +1,4 @@ +.spaceCards { + max-width: 1200px; + margin: auto; +} \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/components/space_cards.test.js b/x-pack/plugins/spaces/public/views/components/space_cards.test.js index b221e6f337afc3..e970c379960361 100644 --- a/x-pack/plugins/spaces/public/views/components/space_cards.test.js +++ b/x-pack/plugins/spaces/public/views/components/space_cards.test.js @@ -5,10 +5,8 @@ */ import React from 'react'; -import { shallow, mount } from 'enzyme'; +import { shallow } from 'enzyme'; import { SpaceCards } from './space_cards'; -import { EuiFlexGroup } from '@elastic/eui'; -import { SpaceCard } from './space_card'; test('it renders without crashing', () => { const space = { @@ -18,29 +16,4 @@ test('it renders without crashing', () => { }; shallow(); -}); - -test('it renders spaces in groups of 3', () => { - function buildSpace(name) { - return { - id: `id-${name}`, - name, - description: `desc-${name}` - }; - } - - const spaces = [ - buildSpace(1), - buildSpace(2), - buildSpace(3), - buildSpace(4) - ]; - - const wrapper = mount(); - - const groups = wrapper.find(EuiFlexGroup); - expect(groups).toHaveLength(2); - - expect(groups.at(0).find(SpaceCard)).toHaveLength(3); - expect(groups.at(1).find(SpaceCard)).toHaveLength(1); -}); +}); \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js index 1827d5bf8519da..8de5ded601092e 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js @@ -14,120 +14,120 @@ import { EuiOverlayMask, EuiAvatar, } from '@elastic/eui'; -import { SpaceCards } from '../components/space_cards'; +import { SpaceCards, SpaceAvatar } from '../components'; import { Notifier } from 'ui/notify'; export class NavControlModal extends Component { - state = { - isOpen: false, - loading: false, - spaces: [] - }; - - notifier = new Notifier(`Spaces`); - - async loadSpaces() { - const { - spacesManager - } = this.props; - - this.setState({ - loading: true - }); - - const spaces = await spacesManager.getSpaces(); - this.setState({ - spaces, - loading: false - }); - } - - componentDidMount() { - const { - activeSpace - } = this.props; - - if (activeSpace && !activeSpace.valid) { - const { error = {} } = activeSpace; - if (error.message) { - this.notifier.error(error.message); - } + state = { + isOpen: false, + loading: false, + spaces: [] + }; + + notifier = new Notifier(`Spaces`); + + async loadSpaces() { + const { + spacesManager + } = this.props; + + this.setState({ + loading: true + }); + + const spaces = await spacesManager.getSpaces(); + this.setState({ + spaces, + loading: false + }); + } + + componentDidMount() { + const { + activeSpace + } = this.props; + + if (activeSpace && !activeSpace.valid) { + const { error = {} } = activeSpace; + if (error.message) { + this.notifier.error(error.message); } } - - render() { - let modal; - if (this.state.isOpen) { - modal = ( - - - - Select a space - - - - - - - ); - } - - return ( -
{this.getActiveSpaceButton()}{modal}
+ } + + render() { + let modal; + if (this.state.isOpen) { + modal = ( + + + + Select a space + + + + + + ); } - getActiveSpaceButton = () => { - const { - activeSpace - } = this.props; - - if (!activeSpace) { - return null; - } + return ( +
{this.getActiveSpaceButton()}{modal}
+ ); + } - if (activeSpace.valid && activeSpace.space) { - return this.getButton( - , - activeSpace.space.name - ); - } else if (activeSpace.error) { - return this.getButton( - , - 'error' - ); - } + getActiveSpaceButton = () => { + const { + activeSpace + } = this.props; + if (!activeSpace) { return null; - }; - - getButton = (linkIcon, linkTitle) => { - return ( - + } + + if (activeSpace.valid && activeSpace.space) { + return this.getButton( + , + activeSpace.space.name + ); + } else if (activeSpace.error) { + return this.getButton( + , + 'error' ); - }; + } - togglePortal = () => { - const isOpening = !this.state.isOpen; - if (isOpening) { - this.loadSpaces(); - } + return null; + }; + + getButton = (linkIcon, linkTitle) => { + return ( + + ); + }; + + togglePortal = () => { + const isOpening = !this.state.isOpen; + if (isOpening) { + this.loadSpaces(); + } - this.setState({ - isOpen: !this.state.isOpen - }); - }; + this.setState({ + isOpen: !this.state.isOpen + }); + }; - closePortal = () => { - this.setState({ - isOpen: false - }); - } + closePortal = () => { + this.setState({ + isOpen: false + }); + } } NavControlModal.propTypes = { diff --git a/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap b/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap index 766dda7129df20..a9a69385339a27 100644 --- a/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap +++ b/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap @@ -2,74 +2,109 @@ exports[`it renders without crashing 1`] = `
- + + + + + + +
+
+ - - - - - +

+ Select your space +

+
-

- Welcome to Kibana. -

-

- Select a space to begin. -

+
+

+ You can change your space at anytime from within Kibana. +

+
+
-
+
+
+
+
+
+ + No spaces match search criteria + +
+
diff --git a/x-pack/plugins/spaces/public/views/space_selector/space_selector.html b/x-pack/plugins/spaces/public/views/space_selector/space_selector.html index 73bf9f23236f7c..2dbf9fac3f68b0 100644 --- a/x-pack/plugins/spaces/public/views/space_selector/space_selector.html +++ b/x-pack/plugins/spaces/public/views/space_selector/space_selector.html @@ -1,3 +1,3 @@ -
+
diff --git a/x-pack/plugins/spaces/public/views/space_selector/space_selector.js b/x-pack/plugins/spaces/public/views/space_selector/space_selector.js index 805dff7546decb..d0dbc65d4566a9 100644 --- a/x-pack/plugins/spaces/public/views/space_selector/space_selector.js +++ b/x-pack/plugins/spaces/public/views/space_selector/space_selector.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { EuiPage, @@ -15,13 +15,19 @@ import { EuiIcon, EuiSpacer, EuiText, - EuiHorizontalRule, + EuiTextColor, + EuiTitle, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { SpaceCards } from '../components/space_cards'; +import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../common/constants'; export class SpaceSelector extends Component { state = { loading: false, + searchTerm: '', spaces: [] }; @@ -53,34 +59,81 @@ export class SpaceSelector extends Component { render() { const { - spaces + spaces, + searchTerm, } = this.state; + let filteredSpaces = spaces; + if (searchTerm) { + filteredSpaces = spaces + .filter(s => s.name.toLowerCase().indexOf(searchTerm) >= 0 || s.description.toLowerCase().indexOf(searchTerm) >= 0); + } + return ( - - - - + + + +
+ +
+ + + + +

Select your space

+
- - -

Welcome to Kibana.

-

Select a space to begin.

-
+ - + + {this.getSearchField()} - + +

You can change your space at anytime from within Kibana.

+
+
- + + + + + { + filteredSpaces.length === 0 && + + + No spaces match search criteria + + }
); } + + getSearchField = () => { + if (!this.props.spaces || this.props.spaces.length < SPACE_SEARCH_COUNT_THRESHOLD) { + return null; + } + return ( + + + + ); + } + + onSearch = (searchTerm = '') => { + this.setState({ + searchTerm: searchTerm.trim().toLowerCase() + }); + } } SpaceSelector.propTypes = { diff --git a/x-pack/plugins/spaces/public/views/space_selector/space_selector.less b/x-pack/plugins/spaces/public/views/space_selector/space_selector.less index 4dcf7fad24b80c..33191ad976b44b 100644 --- a/x-pack/plugins/spaces/public/views/space_selector/space_selector.less +++ b/x-pack/plugins/spaces/public/views/space_selector/space_selector.less @@ -1,30 +1,46 @@ -.application, .euiPanel { - background: #f5f5f5 +@import "~ui/styles/variables"; + +#spaceSelectorRootWrap, #spaceSelectorRoot { + background-color: @globalColorLightestGray; } -.logoHeader { - width: 100%; - text-align: center; +#spaceSelectorRootWrap { + flex-grow: 1; +} +.spaceSelector__page { + padding: 0; } -.spaceWelcomeText, .spaceProfileText { +.spaceSelector__pageContent { + background-color: transparent; + box-shadow: none; + border: none; text-align: center; } -.spacesGroup { - margin: 30px 0 50px 0; +.spaceSelector__heading { + padding: 40px 16px; + background-image: linear-gradient(-194deg, #027AA5 0%, #24A1AB 75%, #3BBBAF 100%, #3EBEB0 100%); + justify-content: center; + text-align: center; } -.euiText .welcomeLarge { - font-size: 2em; - margin-bottom: 15px; +.spaceSelector__logoCircle { + margin: 0 auto; + width: 80px; + height: 80px; + line-height: 80px; + text-align: center; + background-color: @globalColorWhite; + border-radius: 50%; + box-shadow: + 0 6px 12px -1px fadeout(darken(@globalColorBlue, 10%), 80%), + 0 4px 4px -1px fadeout(darken(@globalColorBlue, 10%), 80%), + 0 2px 2px 0 fadeout(darken(@globalColorBlue, 10%), 80%); } -.welcomeMedium { - font-size: 1.4em; -} -.spaceSelectorPageContent { - box-shadow: none; - border: none; +.spaceSelector__searchHolder { + width: 400px; // make sure it's as wide as our default form element width + max-width: 100%; } diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js index 653b566a012e39..01ce98f97cf4f9 100644 --- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js @@ -207,7 +207,6 @@ export function initSpacesApi(server) { async function getSpaceById(client, spaceId) { try { const existingSpace = await client.get('space', spaceId); - console.log(existingSpace); return { id: existingSpace.id, ...existingSpace.attributes From f875cec8a40b3053a85d5624cbc9d466e22f1f4b Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Tue, 26 Jun 2018 09:51:04 -0400 Subject: [PATCH 056/183] Fix RBAC Phase 1 merge from master (#20226) This updates RBAC Phase 1 to work against the latest master. Specifically: 1. Removes `xpack_main`'s `registerLicenseChangeCallback`, which we introduced in `security-app-privs`, in favor of `onLicenseInfoChange`, which was recently added to master 2. Updated `x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js` to be compliant with rxjs v6 --- .../watch_status_and_license_to_initialize.js | 60 +++++++++-------- ...h_status_and_license_to_initialize.test.js | 6 +- .../server/lib/__tests__/xpack_info.js | 66 ------------------- .../xpack_main/server/lib/xpack_info.js | 16 ----- .../apis/saved_objects/delete.js | 2 +- .../apis/saved_objects/get.js | 2 +- .../apis/saved_objects/update.js | 2 +- 7 files changed, 37 insertions(+), 117 deletions(-) diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js index 6e68a4e404705c..14aa3b3ed365ea 100644 --- a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js +++ b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js @@ -3,53 +3,55 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Observable } from 'rxjs'; +import * as Rx from 'rxjs'; +import { map, switchMap, tap } from 'rxjs/operators'; export function watchStatusAndLicenseToInitialize(xpackMainPlugin, downstreamPlugin, initialize) { const xpackInfo = xpackMainPlugin.info; const xpackInfoFeature = xpackInfo.feature(downstreamPlugin.id); const upstreamStatus = xpackMainPlugin.status; - const currentStatus$ = Observable + const currentStatus$ = Rx .of({ state: upstreamStatus.state, message: upstreamStatus.message, }); - const newStatus$ = Observable + const newStatus$ = Rx .fromEvent(upstreamStatus, 'change', null, (previousState, previousMsg, state, message) => { return { state, message, }; }); - const status$ = Observable.merge(currentStatus$, newStatus$); + const status$ = Rx.merge(currentStatus$, newStatus$); - const currentLicense$ = Observable - .of(xpackInfoFeature.getLicenseCheckResults()); - const newLicense$ = Observable - .fromEventPattern(xpackInfoFeature.registerLicenseChangeCallback) - .map(() => xpackInfoFeature.getLicenseCheckResults()); - const license$ = Observable.merge(currentLicense$, newLicense$); + const currentLicense$ = Rx.of(xpackInfoFeature.getLicenseCheckResults()); + const newLicense$ = Rx + .fromEventPattern(xpackInfo.onLicenseInfoChange.bind(xpackInfo)) + .pipe(map(() => xpackInfoFeature.getLicenseCheckResults())); + const license$ = Rx.merge(currentLicense$, newLicense$); - Observable.combineLatest(status$, license$) - .map(([status, license]) => ({ status, license })) - .switchMap(({ status, license }) => { - if (status.state !== 'green') { - return Observable.of({ state: status.state, message: status.message }); - } + Rx.combineLatest(status$, license$) + .pipe( + map(([status, license]) => ({ status, license })), + switchMap(({ status, license }) => { + if (status.state !== 'green') { + return Rx.of({ state: status.state, message: status.message }); + } - return initialize(license) - .then(() => ({ - state: 'green', - message: 'Ready', - })) - .catch((err) => ({ - state: 'red', - message: err.message - })); - }) - .do(({ state, message }) => { - downstreamPlugin.status[state](message); - }) + return initialize(license) + .then(() => ({ + state: 'green', + message: 'Ready', + })) + .catch((err) => ({ + state: 'red', + message: err.message + })); + }), + tap(({ state, message }) => { + downstreamPlugin.status[state](message); + }) + ) .subscribe(); } diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js index 55c2d83ab48f60..7fa66cd5b3d77f 100644 --- a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js +++ b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js @@ -12,9 +12,6 @@ const createMockXpackMainPluginAndFeature = (featureId) => { const mockFeature = { getLicenseCheckResults: jest.fn(), - registerLicenseChangeCallback: (callback) => { - licenseChangeCallbacks.push(callback); - }, mock: { triggerLicenseChange: () => { for (const callback of licenseChangeCallbacks) { @@ -29,6 +26,9 @@ const createMockXpackMainPluginAndFeature = (featureId) => { const mockXpackMainPlugin = { info: { + onLicenseInfoChange: (callback) => { + licenseChangeCallbacks.push(callback); + }, feature: (id) => { if (id === featureId) { return mockFeature; diff --git a/x-pack/plugins/xpack_main/server/lib/__tests__/xpack_info.js b/x-pack/plugins/xpack_main/server/lib/__tests__/xpack_info.js index ded2fe0e5eab6e..260b86d03f17a2 100644 --- a/x-pack/plugins/xpack_main/server/lib/__tests__/xpack_info.js +++ b/x-pack/plugins/xpack_main/server/lib/__tests__/xpack_info.js @@ -462,72 +462,6 @@ describe('XPackInfo', () => { }); }); - it('registerLicenseChangeCallback() does not invoke callbacks if license has not changed', async () => { - const securityFeature = xPackInfo.feature('security'); - const watcherFeature = xPackInfo.feature('watcher'); - - const securityChangeCallback = sinon.stub(); - securityFeature.registerLicenseChangeCallback(securityChangeCallback); - - const watcherChangeCallback = sinon.stub(); - watcherFeature.registerLicenseChangeCallback(watcherChangeCallback); - - mockElasticsearchCluster.callWithInternalUser.returns( - getMockXPackInfoAPIResponse({ mode: 'gold' }) - ); - - await xPackInfo.refreshNow(); - - sinon.assert.notCalled(securityChangeCallback); - sinon.assert.notCalled(watcherChangeCallback); - }); - - it('registerLicenseChangeCallback() invokes callbacks on license change', async () => { - const securityFeature = xPackInfo.feature('security'); - const watcherFeature = xPackInfo.feature('watcher'); - - const securityChangeCallback = sinon.stub(); - securityFeature.registerLicenseChangeCallback(securityChangeCallback); - - const watcherChangeCallback = sinon.stub(); - watcherFeature.registerLicenseChangeCallback(watcherChangeCallback); - - mockElasticsearchCluster.callWithInternalUser.returns( - getMockXPackInfoAPIResponse({ mode: 'platinum' }) - ); - - await xPackInfo.refreshNow(); - - sinon.assert.calledOnce(securityChangeCallback); - sinon.assert.calledOnce(watcherChangeCallback); - }); - - it('registerLicenseChangeCallback() gracefully handles callbacks that throw errors', async () => { - const securityFeature = xPackInfo.feature('security'); - const watcherFeature = xPackInfo.feature('watcher'); - - const securityChangeCallback = sinon.stub().throws(new Error(`Something happened`)); - securityFeature.registerLicenseChangeCallback(securityChangeCallback); - - const watcherChangeCallback = sinon.stub(); - watcherFeature.registerLicenseChangeCallback(watcherChangeCallback); - - mockElasticsearchCluster.callWithInternalUser.returns( - getMockXPackInfoAPIResponse({ mode: 'platinum' }) - ); - - await xPackInfo.refreshNow(); - - sinon.assert.calledOnce(securityChangeCallback); - sinon.assert.calledOnce(watcherChangeCallback); - - sinon.assert.calledWithExactly( - mockServer.log, - ['license', 'error', 'xpack'], - `Error during invocation of license change callback for security. Error: Something happened` - ); - }); - it('getLicenseCheckResults() correctly returns feature specific info.', async () => { const securityFeature = xPackInfo.feature('security'); const watcherFeature = xPackInfo.feature('watcher'); diff --git a/x-pack/plugins/xpack_main/server/lib/xpack_info.js b/x-pack/plugins/xpack_main/server/lib/xpack_info.js index 52f5c5e14d13af..39766c0ac8156c 100644 --- a/x-pack/plugins/xpack_main/server/lib/xpack_info.js +++ b/x-pack/plugins/xpack_main/server/lib/xpack_info.js @@ -36,12 +36,6 @@ export class XPackInfo { */ _licenseInfoChangedListeners = new Set(); - /** - * Feature name <-> license change callback mapping. - * @type {Map} - * @private - */ - _featureLicenseChangeCallbacks = new Map(); /** * Cache that may contain last xpack info API response or error, json representation @@ -224,16 +218,6 @@ export class XPackInfo { this._cache.signature = undefined; }, - /** - * Registers a callback function that will be called whenever the XPack license changes. - * Callback will be invoked after the license change have been applied to this XPack Info instance. - * Callbacks may be asynchronous, but will not be awaited. - * @param {Function} callback Function to call whenever the XPack license changes. - */ - registerLicenseChangeCallback: (callback) => { - this._featureLicenseChangeCallbacks.set(name, callback); - }, - /** * Returns license check results that were previously produced by the `generator` function. * @returns {Object} diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js b/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js index f89073acca7fda..d6bd0d61dd646a 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js @@ -21,7 +21,7 @@ export default function ({ getService }) { expect(resp.body).to.eql({ statusCode: 404, error: 'Not Found', - message: 'Not Found' + message: 'Saved object [dashboard/not-a-real-id] not found' }); }; diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/get.js b/x-pack/test/rbac_api_integration/apis/saved_objects/get.js index 029a44475b12e6..e055d9ef75e483 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/get.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/get.js @@ -34,7 +34,7 @@ export default function ({ getService }) { const expectNotFound = (resp) => { expect(resp.body).to.eql({ error: 'Not Found', - message: 'Not Found', + message: 'Saved object [visualization/foobar] not found', statusCode: 404, }); }; diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/update.js b/x-pack/test/rbac_api_integration/apis/saved_objects/update.js index ae299348847d69..e3740ede1aaafb 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/update.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/update.js @@ -34,7 +34,7 @@ export default function ({ getService }) { expect(resp.body).eql({ statusCode: 404, error: 'Not Found', - message: 'Not Found' + message: 'Saved object [visualization/not an id] not found' }); }; From be6445cb1404276d262e30fe137b959ba7b7a830 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Thu, 28 Jun 2018 13:57:19 -0400 Subject: [PATCH 057/183] Retrying initialize 20 times with a scaling backoff (#20297) * Retrying initialize 20 times with a scaling backoff * Logging error when we are registering the privileges --- .../privileges/privilege_action_registry.js | 27 ++++--- .../privilege_action_registry.test.js | 79 +++++++++++++++++-- .../watch_status_and_license_to_initialize.js | 40 +++++++--- ...h_status_and_license_to_initialize.test.js | 59 +++++++++++++- 4 files changed, 175 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js index 822447dbcecedd..3112265a9b928e 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js @@ -22,16 +22,21 @@ export async function registerPrivilegesWithCluster(server) { const callCluster = getClient(server).callWithInternalUser; - // 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 (equivalentPrivileges(existingPrivileges, expectedPrivileges)) { - server.log(['security', 'debug'], `Kibana Privileges already registered with Elasticearch for ${application}`); - return; - } + 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 (equivalentPrivileges(existingPrivileges, expectedPrivileges)) { + server.log(['security', 'debug'], `Kibana Privileges already registered with Elasticearch for ${application}`); + return; + } - server.log(['security', 'debug'], `Updated Kibana Privileges with Elasticearch for ${application}`); - await callCluster('shield.postPrivileges', { - body: expectedPrivileges - }); + server.log(['security', 'debug'], `Updated Kibana Privileges with Elasticearch for ${application}`); + await callCluster('shield.postPrivileges', { + body: expectedPrivileges + }); + } catch (err) { + server.log(['security', 'error'], `Error registering Kibana Privileges with Elasticsearch for ${application}: ${err.message}`); + throw err; + } } diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js index c7aaa752eaa83b..8e2eac429724b1 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js @@ -19,6 +19,8 @@ const registerPrivilegesWithClusterTest = (description, { savedObjectTypes, expectedPrivileges, existingPrivileges, + throwErrorWhenGettingPrivileges, + throwErrorWhenPuttingPrivileges, assert }) => { const registerMockCallWithInternalUser = () => { @@ -56,8 +58,9 @@ const registerPrivilegesWithClusterTest = (description, { return mockServer; }; - const createExpectUpdatedPrivileges = (mockServer, mockCallWithInternalUser, privileges) => { + const createExpectUpdatedPrivileges = (mockServer, mockCallWithInternalUser, privileges, error) => { return () => { + expect(error).toBeUndefined(); expect(mockCallWithInternalUser).toHaveBeenCalledTimes(2); expect(mockCallWithInternalUser).toHaveBeenCalledWith('shield.getPrivilege', { privilege: defaultApplication, @@ -83,8 +86,9 @@ const registerPrivilegesWithClusterTest = (description, { }; }; - const createExpectDidntUpdatePrivileges = (mockServer, mockCallWithInternalUser) => { + const createExpectDidntUpdatePrivileges = (mockServer, mockCallWithInternalUser, error) => { return () => { + expect(error).toBeUndefined(); expect(mockCallWithInternalUser).toHaveBeenCalledTimes(1); expect(mockCallWithInternalUser).toHaveBeenLastCalledWith('shield.getPrivilege', { privilege: defaultApplication @@ -102,17 +106,49 @@ const registerPrivilegesWithClusterTest = (description, { }; }; + const createExpectErrorThrown = (mockServer, actualError) => { + return (expectedError) => { + expect(actualError).toBe(expectedError); + + const application = settings['xpack.security.rbac.application'] || defaultApplication; + expect(mockServer.log).toHaveBeenCalledWith( + ['security', 'error'], + `Error registering Kibana Privileges with Elasticsearch for ${application}: ${expectedError.message}` + ); + }; + }; + test(description, async () => { const mockServer = createMockServer(); - const mockCallWithInternalUser = registerMockCallWithInternalUser(); - mockCallWithInternalUser.mockImplementationOnce(async () => ({ [defaultApplication]: existingPrivileges })); + const mockCallWithInternalUser = registerMockCallWithInternalUser() + .mockImplementationOnce(async () => { + if (throwErrorWhenGettingPrivileges) { + throw throwErrorWhenGettingPrivileges; + } + + return { + [defaultApplication]: existingPrivileges + }; + }) + .mockImplementationOnce(async () => { + if (throwErrorWhenPuttingPrivileges) { + throw throwErrorWhenPuttingPrivileges; + } + }); + buildPrivilegeMap.mockReturnValue(expectedPrivileges); - await registerPrivilegesWithCluster(mockServer); + let error; + try { + await registerPrivilegesWithCluster(mockServer); + } catch (err) { + error = err; + } assert({ - expectUpdatedPrivileges: createExpectUpdatedPrivileges(mockServer, mockCallWithInternalUser, expectedPrivileges), - expectDidntUpdatePrivileges: createExpectDidntUpdatePrivileges(mockServer, mockCallWithInternalUser), + expectUpdatedPrivileges: createExpectUpdatedPrivileges(mockServer, mockCallWithInternalUser, expectedPrivileges, error), + expectDidntUpdatePrivileges: createExpectDidntUpdatePrivileges(mockServer, mockCallWithInternalUser, error), + expectErrorThrown: createExpectErrorThrown(mockServer, error), mocks: { buildPrivilegeMap } @@ -223,3 +259,32 @@ registerPrivilegesWithClusterTest(`doesn't update privileges when nested propert expectDidntUpdatePrivileges(); } }); + +const gettingPrivilegesError = new Error('Error getting privileges'); +registerPrivilegesWithClusterTest(`throws and logs error when errors getting privileges`, { + throwErrorWhenGettingPrivileges: gettingPrivilegesError, + assert: ({ expectErrorThrown }) => { + expectErrorThrown(gettingPrivilegesError); + } +}); + +const puttingPrivilegesError = new Error('Error putting privileges'); +registerPrivilegesWithClusterTest(`throws and logs error when errors putting privileges`, { + expectedPrivileges: { + kibana: { + foo: false, + bar: false + } + }, + existingPrivileges: { + kibana: { + foo: true, + bar: true + } + }, + throwErrorWhenPuttingPrivileges: puttingPrivilegesError, + assert: ({ expectErrorThrown }) => { + expectErrorThrown(puttingPrivilegesError); + } +}); + diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js index 14aa3b3ed365ea..c0f54b8b2fbf1f 100644 --- a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js +++ b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js @@ -4,7 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ import * as Rx from 'rxjs'; -import { map, switchMap, tap } from 'rxjs/operators'; +import { catchError, mergeMap, map, retryWhen, switchMap, tap } from 'rxjs/operators'; + +export const retryStrategy = ({ + maxAttempts, + scalingDuration, +}) => (errors) => { + return errors.pipe( + mergeMap((error, i) => { + const attempt = i + 1; + + if (attempt >= maxAttempts) { + return Rx.throwError(error); + } + + return Rx.timer(attempt * scalingDuration); + }) + ); +}; export function watchStatusAndLicenseToInitialize(xpackMainPlugin, downstreamPlugin, initialize) { const xpackInfo = xpackMainPlugin.info; @@ -39,15 +56,18 @@ export function watchStatusAndLicenseToInitialize(xpackMainPlugin, downstreamPlu return Rx.of({ state: status.state, message: status.message }); } - return initialize(license) - .then(() => ({ - state: 'green', - message: 'Ready', - })) - .catch((err) => ({ - state: 'red', - message: err.message - })); + return Rx.defer(() => initialize(license)) + .pipe( + retryWhen(retryStrategy({ maxAttempts: 20, scalingDuration: 100 })), + map(() => ({ + state: 'green', + message: 'Ready', + })), + catchError(err => Rx.of({ + state: 'red', + message: err.message + })) + ); }), tap(({ state, message }) => { downstreamPlugin.status[state](message); diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js index 7fa66cd5b3d77f..6964255c535610 100644 --- a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js +++ b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js @@ -115,7 +115,9 @@ test(`sets downstream plugin's status to green when initialize resolves`, (done) }); }); -test(`sets downstream plugin's status to red when initialize rejects`, (done) => { +test(`sets downstream plugin's status to red when initialize rejects 20 times`, (done) => { + jest.useFakeTimers(); + const pluginId = 'foo-plugin'; const errorMessage = 'the error message'; const { mockXpackMainPlugin, mockFeature } = createMockXpackMainPluginAndFeature(pluginId); @@ -123,18 +125,71 @@ test(`sets downstream plugin's status to red when initialize rejects`, (done) => const licenseCheckResults = Symbol(); mockFeature.mock.setLicenseCheckResults(licenseCheckResults); const downstreamPlugin = createMockDownstreamPlugin(pluginId); - const initializeMock = jest.fn().mockImplementation(() => Promise.reject(new Error(errorMessage))); + + let initializeCount = 0; + const initializeMock = jest.fn().mockImplementation(() => { + ++initializeCount; + + // everytime this is called, we have to wait for a new promise to be resolved + // allowing the Promise the we return below to run, and then advance the timers + setImmediate(async () => { + await Promise.resolve(); + jest.advanceTimersByTime(initializeCount * 100); + }); + return Promise.reject(new Error(errorMessage)); + }); watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); expect(initializeMock).toHaveBeenCalledTimes(1); expect(initializeMock).toHaveBeenCalledWith(licenseCheckResults); downstreamPlugin.status.red.mockImplementation(message => { + expect(initializeCount).toBe(20); expect(message).toBe(errorMessage); done(); }); }); +test(`sets downstream plugin's status to green when initialize resolves after rejecting 10 times`, (done) => { + jest.useFakeTimers(); + + const pluginId = 'foo-plugin'; + const errorMessage = 'the error message'; + const { mockXpackMainPlugin, mockFeature } = createMockXpackMainPluginAndFeature(pluginId); + mockXpackMainPlugin.mock.setStatus('green'); + const licenseCheckResults = Symbol(); + mockFeature.mock.setLicenseCheckResults(licenseCheckResults); + const downstreamPlugin = createMockDownstreamPlugin(pluginId); + + let initializeCount = 0; + const initializeMock = jest.fn().mockImplementation(() => { + ++initializeCount; + + // everytime this is called, we have to wait for a new promise to be resolved + // allowing the Promise the we return below to run, and then advance the timers + setImmediate(async () => { + await Promise.resolve(); + jest.advanceTimersByTime(initializeCount * 100); + }); + + if (initializeCount >= 10) { + return Promise.resolve(); + } + + return Promise.reject(new Error(errorMessage)); + }); + + watchStatusAndLicenseToInitialize(mockXpackMainPlugin, downstreamPlugin, initializeMock); + + expect(initializeMock).toHaveBeenCalledTimes(1); + expect(initializeMock).toHaveBeenCalledWith(licenseCheckResults); + downstreamPlugin.status.green.mockImplementation(message => { + expect(initializeCount).toBe(10); + expect(message).toBe('Ready'); + done(); + }); +}); + test(`calls initialize twice when it gets a new license and the status is green`, (done) => { const pluginId = 'foo-plugin'; const { mockXpackMainPlugin, mockFeature } = createMockXpackMainPluginAndFeature(pluginId); From e3c1a991e44c0b1087ad11663180e90145e2b17b Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Fri, 29 Jun 2018 11:18:58 -0400 Subject: [PATCH 058/183] Alternate legacy fallback (#20322) * Beginning to use alternate callWithRequest fallback * Only use legacy fallback when user has "some" privileges on index * Logging useLegacyFallback when there's an authorization failure * Adding tests, logging failure during find no types fallback * Switching to using an enum instead of success/useLegacyFallback * Using _execute to share some of the structure * Moving comment to where it belongs * No longer audit logging when we use the legacy fallback --- x-pack/plugins/security/index.js | 18 +- .../lib/authorization/has_privileges.js | 197 +++--- .../lib/authorization/has_privileges.test.js | 385 +---------- .../secure_saved_objects_client.js | 170 ++--- .../secure_saved_objects_client.test.js | 603 +++++++++++++----- .../apis/saved_objects/bulk_get.js | 4 +- .../apis/saved_objects/create.js | 17 +- .../apis/saved_objects/delete.js | 23 +- .../apis/saved_objects/find.js | 82 ++- .../apis/saved_objects/get.js | 6 +- .../apis/saved_objects/update.js | 23 +- 11 files changed, 757 insertions(+), 771 deletions(-) diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 8e8ef10a9e2736..c72ebfa74e1c04 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -121,23 +121,21 @@ export const security = (kibana) => new kibana.Plugin({ request, }) => { const adminCluster = server.plugins.elasticsearch.getCluster('admin'); + const { callWithRequest, callWithInternalUser } = adminCluster; + const callCluster = (...args) => callWithRequest(request, ...args); - if (!xpackInfoFeature.getLicenseCheckResults().allowRbac) { - const { callWithRequest } = adminCluster; - const callCluster = (...args) => callWithRequest(request, ...args); - - const repository = savedObjects.getSavedObjectsRepository(callCluster); + const callWithRequestRepository = savedObjects.getSavedObjectsRepository(callCluster); - return new savedObjects.SavedObjectsClient(repository); + if (!xpackInfoFeature.getLicenseCheckResults().allowRbac) { + return new savedObjects.SavedObjectsClient(callWithRequestRepository); } const hasPrivileges = hasPrivilegesWithRequest(request); - const { callWithInternalUser } = adminCluster; - - const repository = savedObjects.getSavedObjectsRepository(callWithInternalUser); + const internalRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser); return new SecureSavedObjectsClient({ - repository, + internalRepository, + callWithRequestRepository, errors: savedObjects.SavedObjectsClient.errors, hasPrivileges, auditLogger, diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.js index 67de50730959c0..2f68c7fadaf95d 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.js @@ -6,96 +6,12 @@ import { getClient } from '../../../../../server/lib/get_client_shield'; import { DEFAULT_RESOURCE } from '../../../common/constants'; -import { buildPrivilegeMap, getVersionPrivilege, getLoginPrivilege } from '../privileges'; - -const hasApplicationPrivileges = async (callWithRequest, request, kibanaVersion, application, privileges) => { - const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application, - resources: [DEFAULT_RESOURCE], - privileges - }] - } - }); - - const hasPrivileges = privilegeCheck.application[application][DEFAULT_RESOURCE]; - - // We include the login action in all privileges, so the existence of it and not the version privilege - // lets us know that we're running in an incorrect configuration. Without the login privilege check, we wouldn't - // know whether the user just wasn't authorized for this instance of Kibana in general - if (!hasPrivileges[getVersionPrivilege(kibanaVersion)] && hasPrivileges[getLoginPrivilege()]) { - throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.'); - } - - return { - username: privilegeCheck.username, - hasAllRequested: privilegeCheck.has_all_requested, - privileges: hasPrivileges - }; -}; - -const hasLegacyPrivileges = async ( - savedObjectTypes, - deprecationLogger, - callWithRequest, - request, - kibanaVersion, - application, - kibanaIndex, - privileges -) => { - const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { - body: { - index: [{ - names: [kibanaIndex], - privileges: ['read', 'index'] - }] - } - }); - - const createPrivileges = (cb) => { - return privileges.reduce((acc, name) => { - acc[name] = cb(name); - return acc; - }, {}); - }; +import { getVersionPrivilege, getLoginPrivilege } from '../privileges'; - const logDeprecation = () => { - deprecationLogger( - `Relying on implicit privileges determined from the index privileges is deprecated and will be removed in Kibana 7.0` - ); - }; - - // if they have the index privilege, then we grant them all actions - if (privilegeCheck.index[kibanaIndex].index) { - logDeprecation(); - const implicitPrivileges = createPrivileges(() => true); - return { - username: privilegeCheck.username, - hasAllRequested: true, - privileges: implicitPrivileges - }; - } - - // if they have the read privilege, then we only grant them the read actions - if (privilegeCheck.index[kibanaIndex].read) { - logDeprecation(); - const privilegeMap = buildPrivilegeMap(savedObjectTypes, application, kibanaVersion); - const implicitPrivileges = createPrivileges(name => privilegeMap.read.actions.includes(name)); - - return { - username: privilegeCheck.username, - hasAllRequested: Object.values(implicitPrivileges).every(x => x), - privileges: implicitPrivileges, - }; - } - - return { - username: privilegeCheck.username, - hasAllRequested: false, - privileges: createPrivileges(() => false) - }; +export const HAS_PRIVILEGES_RESULT = { + UNAUTHORIZED: Symbol(), + AUTHORIZED: Symbol(), + LEGACY: Symbol(), }; export function hasPrivilegesWithServer(server) { @@ -105,45 +21,86 @@ export function hasPrivilegesWithServer(server) { const kibanaVersion = config.get('pkg.version'); const application = config.get('xpack.security.rbac.application'); const kibanaIndex = config.get('kibana.index'); - const savedObjectTypes = server.savedObjects.types; - const deprecationLogger = (msg) => server.log(['warning', 'deprecated', 'security'], msg); + + const loginPrivilege = getLoginPrivilege(); + const versionPrivilege = getVersionPrivilege(kibanaVersion); return function hasPrivilegesWithRequest(request) { - return async function hasPrivileges(privileges) { - const loginPrivilege = getLoginPrivilege(); - const versionPrivilege = getVersionPrivilege(kibanaVersion); + const hasApplicationPrivileges = async (privileges) => { + const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application, + resources: [DEFAULT_RESOURCE], + privileges + }] + } + }); + + const hasPrivileges = privilegeCheck.application[application][DEFAULT_RESOURCE]; + + // We include the login action in all privileges, so the existence of it and not the version privilege + // lets us know that we're running in an incorrect configuration. Without the login privilege check, we wouldn't + // know whether the user just wasn't authorized for this instance of Kibana in general + if (!hasPrivileges[getVersionPrivilege(kibanaVersion)] && hasPrivileges[getLoginPrivilege()]) { + throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.'); + } + + return { + username: privilegeCheck.username, + hasAllRequested: privilegeCheck.has_all_requested, + privileges: hasPrivileges + }; + }; + + const hasPrivilegesOnKibanaIndex = async () => { + const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { + body: { + index: [{ + names: [kibanaIndex], + privileges: ['create', 'delete', 'read', 'view_index_metadata'] + }] + } + }); + + return Object.values(privilegeCheck.index[kibanaIndex]).includes(true); + }; + + return async function hasPrivileges(privileges) { const allPrivileges = [versionPrivilege, loginPrivilege, ...privileges]; - let privilegesCheck = await hasApplicationPrivileges( - callWithRequest, - request, - kibanaVersion, - application, - allPrivileges - ); - - if (!privilegesCheck.privileges[loginPrivilege]) { - privilegesCheck = await hasLegacyPrivileges( - savedObjectTypes, - deprecationLogger, - callWithRequest, - request, - kibanaVersion, - application, - kibanaIndex, - allPrivileges - ); + const privilegesCheck = await hasApplicationPrivileges(allPrivileges); + + const username = privilegesCheck.username; + + // We don't want to expose the version privilege to consumers, as it's an implementation detail only to detect version mismatch + const missing = Object.keys(privilegesCheck.privileges) + .filter(p => !privilegesCheck.privileges[p]) + .filter(p => p !== versionPrivilege); + + if (privilegesCheck.hasAllRequested) { + return { + result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + username, + missing, + }; } - const success = privilegesCheck.hasAllRequested; + if (!privilegesCheck.privileges[loginPrivilege] && await hasPrivilegesOnKibanaIndex()) { + const msg = `Relying on implicit privileges determined from the index privileges is deprecated and will be removed in Kibana 7.0`; + server.log(['warning', 'deprecated', 'security'], msg); + + return { + result: HAS_PRIVILEGES_RESULT.LEGACY, + username, + missing, + }; + } return { - success, - // We don't want to expose the version privilege to consumers, as it's an implementation detail only to detect version mismatch - missing: Object.keys(privilegesCheck.privileges) - .filter(key => privilegesCheck.privileges[key] === false) - .filter(p => p !== versionPrivilege), - username: privilegesCheck.username, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, + missing, }; }; }; diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js index 5203d477b16c81..7f38ba3965b786 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js @@ -4,18 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { hasPrivilegesWithServer } from './has_privileges'; +import { hasPrivilegesWithServer, HAS_PRIVILEGES_RESULT } from './has_privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; import { DEFAULT_RESOURCE } from '../../../common/constants'; -import { getLoginPrivilege, getVersionPrivilege, buildPrivilegeMap } from '../privileges'; +import { getLoginPrivilege, getVersionPrivilege } from '../privileges'; jest.mock('../../../../../server/lib/get_client_shield', () => ({ getClient: jest.fn() })); -const defaultKibanaIndex = 'default-kibana-index'; const defaultVersion = 'default-version'; const defaultApplication = 'default-application'; +const defaultKibanaIndex = 'default-index'; const savedObjectTypes = ['foo-type', 'bar-type']; const createMockServer = ({ settings = {} } = {}) => { @@ -27,9 +27,9 @@ const createMockServer = ({ settings = {} } = {}) => { }; const defaultSettings = { - 'kibana.index': defaultKibanaIndex, 'pkg.version': defaultVersion, - 'xpack.security.rbac.application': defaultApplication + 'xpack.security.rbac.application': defaultApplication, + 'kibana.index': defaultKibanaIndex, }; mockServer.config().get.mockImplementation(key => { @@ -55,10 +55,8 @@ const mockApplicationPrivilegeResponse = ({ hasAllRequested, privileges, applica }; }; -const mockLegacyResponse = ({ hasAllRequested, privileges, index = defaultKibanaIndex, username = '' }) => { +const mockKibanaIndexPrivilegesResponse = ({ privileges, index = defaultKibanaIndex }) => { return { - username: username, - has_all_requested: hasAllRequested, index: { [index]: privileges } @@ -86,7 +84,7 @@ const expectDeprecationLogged = (mockServer) => { expect(mockServer.log).toHaveBeenCalledWith(['warning', 'deprecated', 'security'], expect.stringContaining('deprecated')); }; -test(`returns success of true if they have all application privileges`, async () => { +test(`returns authorized if they have all application privileges`, async () => { const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; const username = 'foo-username'; const mockServer = createMockServer(); @@ -122,13 +120,13 @@ test(`returns success of true if they have all application privileges`, async () } }); expect(result).toEqual({ - success: true, + result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + username, missing: [], - username }); }); -test(`returns success of false if they have only one application privilege`, async () => { +test(`returns unauthorized they have only one application privilege`, async () => { const privilege1 = `action:saved_objects/${savedObjectTypes[0]}/get`; const privilege2 = `action:saved_objects/${savedObjectTypes[0]}/create`; const username = 'foo-username'; @@ -166,9 +164,9 @@ test(`returns success of false if they have only one application privilege`, asy } }); expect(result).toEqual({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [privilege2], - username }); }); @@ -193,90 +191,8 @@ test(`throws error if missing version privilege and has login privilege`, async expectNoDeprecationLogged(mockServer); }); -test(`uses application privileges if the user has the login privilege`, async () => { - const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; - const username = 'foo-username'; - const mockServer = createMockServer(); - const callWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - [privilege]: false, - }, - username, - }), - ]); - - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); - const privileges = [privilege]; - const result = await hasPrivileges(privileges); - - expectNoDeprecationLogged(mockServer); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [DEFAULT_RESOURCE], - privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges - ] - }] - } - }); - expect(result).toEqual({ - success: false, - missing: [...privileges], - username, - }); -}); - -test(`returns success of false using application privileges if the user has the login privilege`, async () => { - const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; - const username = 'foo-username'; - const mockServer = createMockServer(); - const callWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, - [privilege]: false, - }, - username, - }), - ]); - - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); - const privileges = [privilege]; - const result = await hasPrivileges(privileges); - - expectNoDeprecationLogged(mockServer); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [DEFAULT_RESOURCE], - privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges - ] - }] - } - }); - expect(result).toEqual({ - success: false, - missing: [...privileges], - username, - }); -}); - describe('legacy fallback with no application privileges', () => { - test(`returns success of false if the user has no legacy privileges`, async () => { + test(`returns unauthorized if they have no privileges on the kibana index`, async () => { const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; const username = 'foo-username'; const mockServer = createMockServer(); @@ -290,13 +206,13 @@ describe('legacy fallback with no application privileges', () => { }, username, }), - mockLegacyResponse({ - hasAllRequested: false, + mockKibanaIndexPrivilegesResponse({ privileges: { + create: false, + delete: false, read: false, - index: false, + view_index_metadata: false, }, - username, }) ]); @@ -322,198 +238,20 @@ describe('legacy fallback with no application privileges', () => { body: { index: [{ names: [ defaultKibanaIndex ], - privileges: ['read', 'index'] - }] - } - }); - expect(result).toEqual({ - success: false, - missing: [getLoginPrivilege(), ...privileges], - username, - }); - }); - - test(`returns success of true if the user has index privilege on kibana index`, async () => { - const privilege = 'something-completely-arbitrary'; - const username = 'foo-username'; - const mockServer = createMockServer(); - const callWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: false, - [privilege]: false, - }, - username, - }), - mockLegacyResponse({ - hasAllRequested: false, - privileges: { - read: false, - index: true, - }, - username, - }) - ]); - - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); - const privileges = ['foo']; - const result = await hasPrivileges(privileges); - - expectDeprecationLogged(mockServer); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [DEFAULT_RESOURCE], - privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges - ] - }] - } - }); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - index: [{ - names: [ defaultKibanaIndex ], - privileges: ['read', 'index'] - }] - } - }); - expect(result).toEqual({ - success: true, - missing: [], - username, - }); - }); - - test(`returns success of false if the user has the read privilege on kibana index but the privilege isn't a read action`, async () => { - const privilege = 'something-completely-arbitrary'; - const username = 'foo-username'; - const mockServer = createMockServer(); - const callWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: false, - [privilege]: false, - }, - username, - }), - mockLegacyResponse({ - hasAllRequested: false, - privileges: { - read: true, - index: false, - }, - username, - }) - ]); - - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); - const privileges = [privilege]; - const result = await hasPrivileges(privileges); - - expectDeprecationLogged(mockServer); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [DEFAULT_RESOURCE], - privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges - ] - }] - } - }); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - index: [{ - names: [ defaultKibanaIndex ], - privileges: ['read', 'index'] + privileges: ['create', 'delete', 'read', 'view_index_metadata'] }] } }); expect(result).toEqual({ - success: false, - missing: [ privilege ], + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, username, + missing: [getLoginPrivilege(), ...privileges], }); }); - test(`returns success of false if the user has the read privilege on kibana index but one privilege isn't a read action`, async () => { - const privilegeMap = buildPrivilegeMap(savedObjectTypes, defaultApplication, defaultVersion); - - const actions = privilegeMap.read.actions.filter(a => a !== getVersionPrivilege(defaultVersion) && a !== getLoginPrivilege()); - for (const action of actions) { - const privilege1 = 'something-completely-arbitrary'; - const privilege2 = action; - const username = 'foo-username'; - const mockServer = createMockServer(); - const callWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: false, - [privilege1]: false, - [privilege2]: true - }, - username, - }), - mockLegacyResponse({ - hasAllRequested: false, - privileges: { - read: true, - index: false, - }, - username, - }) - ]); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); - const privileges = [privilege1, privilege2]; - const result = await hasPrivileges(privileges); - - expectDeprecationLogged(mockServer); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [DEFAULT_RESOURCE], - privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges - ] - }] - } - }); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - index: [{ - names: [ defaultKibanaIndex ], - privileges: ['read', 'index'] - }] - } - }); - expect(result).toEqual({ - success: false, - missing: [ privilege1 ], - username, - }); - } - }); - - test(`returns success of true if the user has the read privilege on kibana index and the privilege is a read action`, async () => { - const privilegeMap = buildPrivilegeMap(savedObjectTypes, defaultApplication, defaultVersion); - for (const action of privilegeMap.read.actions) { - const privilege = action; + ['create', 'delete', 'read', 'view_index_metadata'].forEach(indexPrivilege => { + test(`returns legacy if they have ${indexPrivilege} privilege on the kibana index`, async () => { + const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; const username = 'foo-username'; const mockServer = createMockServer(); const callWithRequest = createMockCallWithRequest([ @@ -526,13 +264,14 @@ describe('legacy fallback with no application privileges', () => { }, username, }), - mockLegacyResponse({ - hasAllRequested: false, + mockKibanaIndexPrivilegesResponse({ privileges: { - read: true, - index: false, + create: false, + delete: false, + read: false, + view_index_metadata: false, + [indexPrivilege]: true }, - username, }) ]); @@ -558,75 +297,15 @@ describe('legacy fallback with no application privileges', () => { body: { index: [{ names: [ defaultKibanaIndex ], - privileges: ['read', 'index'] + privileges: ['create', 'delete', 'read', 'view_index_metadata'] }] } }); expect(result).toEqual({ - success: true, - missing: [], + result: HAS_PRIVILEGES_RESULT.LEGACY, username, + missing: [getLoginPrivilege(), ...privileges], }); - } - }); - - test(`returns success of true if the user has the read privilege on kibana index and all privileges are read actions`, async () => { - const privilegeMap = buildPrivilegeMap(savedObjectTypes, defaultApplication, defaultVersion); - const privileges = privilegeMap.read.actions; - const username = 'foo-username'; - const mockServer = createMockServer(); - const callWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: false, - ...privileges.reduce((acc, name) => { - acc[name] = false; - return acc; - }, {}) - }, - username, - }), - mockLegacyResponse({ - hasAllRequested: false, - privileges: { - read: true, - index: false, - }, - username, - }) - ]); - - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); - const result = await hasPrivileges(privileges); - - expectDeprecationLogged(mockServer); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [DEFAULT_RESOURCE], - privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges - ] - }] - } - }); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - index: [{ - names: [ defaultKibanaIndex ], - privileges: ['read', 'index'] - }] - } - }); - expect(result).toEqual({ - success: true, - missing: [], - username, }); }); }); diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js index 83e6123f06df5f..fce39c03d6e06d 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js @@ -5,6 +5,7 @@ */ import { get, uniq } from 'lodash'; +import { HAS_PRIVILEGES_RESULT } from '../authorization/has_privileges'; const getPrivilege = (type, action) => { return `action:saved_objects/${type}/${action}`; @@ -14,124 +15,147 @@ export class SecureSavedObjectsClient { constructor(options) { const { errors, - repository, + internalRepository, + callWithRequestRepository, hasPrivileges, auditLogger, savedObjectTypes, } = options; this.errors = errors; - this._repository = repository; + this._internalRepository = internalRepository; + this._callWithRequestRepository = callWithRequestRepository; this._hasPrivileges = hasPrivileges; this._auditLogger = auditLogger; this._savedObjectTypes = savedObjectTypes; } async create(type, attributes = {}, options = {}) { - await this._performAuthorizationCheck(type, 'create', { + return await this._execute( type, - attributes, - options, - }); - - return await this._repository.create(type, attributes, options); + 'create', + { type, attributes, options }, + repository => repository.create(type, attributes, options), + ); } async bulkCreate(objects, options = {}) { const types = uniq(objects.map(o => o.type)); - await this._performAuthorizationCheck(types, 'bulk_create', { - objects, - options, - }); - - return await this._repository.bulkCreate(objects, options); + return await this._execute( + types, + 'bulk_create', + { objects, options }, + repository => repository.bulkCreate(objects, options), + ); } async delete(type, id) { - await this._performAuthorizationCheck(type, 'delete', { + return await this._execute( type, - id, - }); - - return await this._repository.delete(type, id); + 'delete', + { type, id }, + repository => repository.delete(type, id), + ); } async find(options = {}) { - const action = 'find'; - - // when we have the type or types, it makes our life easy if (options.type) { - await this._performAuthorizationCheck(options.type, action, { options }); - return await this._repository.find(options); + return await this._findWithTypes(options); } - // otherwise, we have to filter for only their authorized types - const types = this._savedObjectTypes; - const typesToPrivilegesMap = new Map(types.map(type => [type, getPrivilege(type, action)])); - const hasPrivilegesResult = await this._hasSavedObjectPrivileges(Array.from(typesToPrivilegesMap.values())); - const authorizedTypes = Array.from(typesToPrivilegesMap.entries()) - .filter(([ , privilege]) => !hasPrivilegesResult.missing.includes(privilege)) - .map(([type]) => type); - - if (authorizedTypes.length === 0) { - this._auditLogger.savedObjectsAuthorizationFailure( - hasPrivilegesResult.username, - action, - types, - hasPrivilegesResult.missing, - { options } - ); - throw this.errors.decorateForbiddenError(new Error(`Not authorized to find saved_object`)); - } - this._auditLogger.savedObjectsAuthorizationSuccess(hasPrivilegesResult.username, action, authorizedTypes, { options }); + return await this._findAcrossAllTypes(options); + } - return await this._repository.find({ - ...options, - type: authorizedTypes - }); + async _findWithTypes(options) { + return await this._execute( + options.type, + 'find', + { options }, + repository => repository.find(options) + ); } async bulkGet(objects = []) { const types = uniq(objects.map(o => o.type)); - await this._performAuthorizationCheck(types, 'bulk_get', { - objects, - }); - - return await this._repository.bulkGet(objects); + return await this._execute( + types, + 'bulk_get', + { objects }, + repository => repository.bulkGet(objects) + ); } async get(type, id) { - await this._performAuthorizationCheck(type, 'get', { + return await this._execute( type, - id, - }); - - return await this._repository.get(type, id); + 'get', + { type, id }, + repository => repository.get(type, id) + ); } async update(type, id, attributes, options = {}) { - await this._performAuthorizationCheck(type, 'update', { + return await this._execute( type, - id, - attributes, - options, - }); - - return await this._repository.update(type, id, attributes, options); + 'update', + { type, id, attributes, options }, + repository => repository.update(type, id, attributes, options) + ); } - async _performAuthorizationCheck(typeOrTypes, action, args) { + async _execute(typeOrTypes, action, args, fn) { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; const privileges = types.map(type => getPrivilege(type, action)); - const result = await this._hasSavedObjectPrivileges(privileges); - - if (result.success) { - this._auditLogger.savedObjectsAuthorizationSuccess(result.username, action, types, args); - } else { - this._auditLogger.savedObjectsAuthorizationFailure(result.username, action, types, result.missing, args); - const msg = `Unable to ${action} ${types.sort().join(',')}, missing ${result.missing.sort().join(',')}`; - throw this.errors.decorateForbiddenError(new Error(msg)); + const { result, username, missing } = await this._hasSavedObjectPrivileges(privileges); + + switch (result) { + case HAS_PRIVILEGES_RESULT.AUTHORIZED: + this._auditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); + return await fn(this._internalRepository); + case HAS_PRIVILEGES_RESULT.LEGACY: + return await fn(this._callWithRequestRepository); + case HAS_PRIVILEGES_RESULT.UNAUTHORIZED: + this._auditLogger.savedObjectsAuthorizationFailure(username, action, types, missing, args); + const msg = `Unable to ${action} ${types.sort().join(',')}, missing ${missing.sort().join(',')}`; + throw this.errors.decorateForbiddenError(new Error(msg)); + default: + throw new Error('Unexpected result from hasPrivileges'); + } + } + + async _findAcrossAllTypes(options) { + const action = 'find'; + + // we have to filter for only their authorized types + const types = this._savedObjectTypes; + const typesToPrivilegesMap = new Map(types.map(type => [type, getPrivilege(type, action)])); + const { result, username, missing } = await this._hasSavedObjectPrivileges(Array.from(typesToPrivilegesMap.values())); + + if (result === HAS_PRIVILEGES_RESULT.LEGACY) { + return await this._callWithRequestRepository.find(options); + } + + const authorizedTypes = Array.from(typesToPrivilegesMap.entries()) + .filter(([ , privilege]) => !missing.includes(privilege)) + .map(([type]) => type); + + if (authorizedTypes.length === 0) { + this._auditLogger.savedObjectsAuthorizationFailure( + username, + action, + types, + missing, + { options } + ); + throw this.errors.decorateForbiddenError(new Error(`Not authorized to find saved_object`)); } + + this._auditLogger.savedObjectsAuthorizationSuccess(username, action, authorizedTypes, { options }); + + return await this._internalRepository.find({ + ...options, + type: authorizedTypes + }); } async _hasSavedObjectPrivileges(privileges) { diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js index 0f9fa6610f683f..4d77c48c56b950 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js @@ -5,6 +5,7 @@ */ import { SecureSavedObjectsClient } from './secure_saved_objects_client'; +import { HAS_PRIVILEGES_RESULT } from '../authorization/has_privileges'; const createMockErrors = () => { const forbiddenError = new Error('Mock ForbiddenError'); @@ -36,16 +37,37 @@ describe('#errors', () => { }); describe('#create', () => { - test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/create`]); + 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 mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [ `action:saved_objects/${type}/create` ], - username })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ @@ -74,28 +96,39 @@ describe('#create', () => { expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + test(`returns result of internalRepository.create when authorized`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + create: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + username, + })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - errors: mockErrors, + internalRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); + const attributes = Symbol(); + const options = Symbol(); - await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); + const result = await client.create(type, attributes, options); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/create`]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(result).toBe(returnValue); + expect(mockRepository.create).toHaveBeenCalledWith(type, attributes, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'create', [type], { + type, + attributes, + options, + }); }); - test(`calls and returns result of repository.create`, async () => { + test(`returns result of callWithRequestRepository.create when legacy`, async () => { const type = 'foo'; const username = Symbol(); const returnValue = Symbol(); @@ -103,12 +136,15 @@ describe('#create', () => { create: jest.fn().mockReturnValue(returnValue) }; const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: true, + result: HAS_PRIVILEGES_RESULT.LEGACY, username, + missing: [ + `action:saved_objects/${type}/create` + ], })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - repository: mockRepository, + callWithRequestRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); @@ -119,27 +155,44 @@ describe('#create', () => { expect(result).toBe(returnValue); expect(mockRepository.create).toHaveBeenCalledWith(type, attributes, options); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'create', [type], { - type, - attributes, - options, - }); }); }); describe('#bulkCreate', () => { - test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.bulkCreate([{ type }])).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/bulk_create`]); + 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 mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [ `action:saved_objects/${type1}/bulk_create` ], - username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ @@ -173,28 +226,42 @@ describe('#bulkCreate', () => { ); }); - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); + test(`returns result of internalRepository.bulkCreate when authorized`, async () => { + const username = Symbol(); + const type1 = 'foo'; + const type2 = 'bar'; + const returnValue = Symbol(); + const mockRepository = { + bulkCreate: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + username, + })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - errors: mockErrors, + internalRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); + const objects = [ + { type: type1, otherThing: 'sup' }, + { type: type2, otherThing: 'everyone' }, + ]; + const options = Symbol(); - await expect(client.bulkCreate([{ type }])).rejects.toThrowError(mockErrors.generalError); + const result = await client.bulkCreate(objects, options); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/bulk_create`]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(result).toBe(returnValue); + expect(mockRepository.bulkCreate).toHaveBeenCalledWith(objects, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_create', [type1, type2], { + objects, + options, + }); }); - test(`calls and returns result of repository.bulkCreate`, async () => { + test(`returns result of callWithRequestRepository.bulkCreate when legacy`, async () => { const username = Symbol(); const type1 = 'foo'; const type2 = 'bar'; @@ -203,12 +270,16 @@ describe('#bulkCreate', () => { bulkCreate: jest.fn().mockReturnValue(returnValue) }; const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: true, + result: HAS_PRIVILEGES_RESULT.LEGACY, username, + missing: [ + `action:saved_objects/${type1}/bulk_create`, + `action:saved_objects/${type2}/bulk_create`, + ], })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - repository: mockRepository, + callWithRequestRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); @@ -223,24 +294,42 @@ describe('#bulkCreate', () => { expect(result).toBe(returnValue); expect(mockRepository.bulkCreate).toHaveBeenCalledWith(objects, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_create', [type1, type2], { - objects, - options, - }); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); }); describe('#delete', () => { - test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/delete`]); + 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 mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [ `action:saved_objects/${type}/delete` ], - username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ @@ -267,28 +356,37 @@ describe('#delete', () => { expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + test(`returns result of internalRepository.delete when authorized`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + delete: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + username, + })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - errors: mockErrors, + internalRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); + const id = Symbol(); - await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); + const result = await client.delete(type, id); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/delete`]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(result).toBe(returnValue); + expect(mockRepository.delete).toHaveBeenCalledWith(type, id); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete', [type], { + type, + id, + }); }); - test(`calls and returns result of repository.delete`, async () => { + test(`returns result of internalRepository.delete when legacy`, async () => { const type = 'foo'; const username = Symbol(); const returnValue = Symbol(); @@ -296,12 +394,15 @@ describe('#delete', () => { delete: jest.fn().mockReturnValue(returnValue) }; const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: true, + result: HAS_PRIVILEGES_RESULT.LEGACY, username, + missing: [ + `action:saved_objects/${type}/delete` + ], })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - repository: mockRepository, + callWithRequestRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); @@ -312,31 +413,49 @@ describe('#delete', () => { expect(result).toBe(returnValue); expect(mockRepository.delete).toHaveBeenCalledWith(type, id); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete', [type], { - type, - id, - }); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); }); describe('#find', () => { describe('type', () => { - test(`throws decorated ForbiddenError when type is sinuglar and user isn't authorized`, async () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + 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 mockRepository = {}; const mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [ `action:saved_objects/${type}/find` ], - username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - repository: mockRepository, + internalRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); @@ -358,23 +477,21 @@ describe('#find', () => { expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); - test(`throws decorated ForbiddenError when type is an array and user isn't authorized for one type`, async () => { + test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { const type1 = 'foo'; const type2 = 'bar'; const username = Symbol(); - const mockRepository = {}; const mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [ `action:saved_objects/${type1}/find` ], - username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - repository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); @@ -396,19 +513,19 @@ describe('#find', () => { expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); - test(`throws decorated ForbiddenError when type is an array and user isn't authorized for either type`, async () => { + test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { const type1 = 'foo'; const type2 = 'bar'; const username = Symbol(); const mockRepository = {}; const mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [ `action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find` ], - username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ @@ -435,28 +552,36 @@ describe('#find', () => { expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + test(`returns result of internalRepository.find when authorized`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + find: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + username, + })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - errors: mockErrors, + internalRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); + const options = { type }; - await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); + const result = await client.find(options); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(result).toBe(returnValue); + expect(mockRepository.find).toHaveBeenCalledWith({ type }); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'find', [type], { + options, + }); }); - test(`calls and returns result of repository.find`, async () => { + test(`returns result of callWithRequestRepository.find when legacy`, async () => { const type = 'foo'; const username = Symbol(); const returnValue = Symbol(); @@ -464,12 +589,15 @@ describe('#find', () => { find: jest.fn().mockReturnValue(returnValue) }; const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: true, + result: HAS_PRIVILEGES_RESULT.LEGACY, username, + missing: [ + `action:saved_objects/${type}/find` + ], })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - repository: mockRepository, + callWithRequestRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); @@ -480,24 +608,47 @@ describe('#find', () => { expect(result).toBe(returnValue); expect(mockRepository.find).toHaveBeenCalledWith({ type }); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'find', [type], { - options, - }); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); }); describe('no type', () => { - test(`throws decorated ForbiddenError when user has no authorized types`, async () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const mockRepository = {}; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + repository: mockRepository, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + savedObjectTypes: [type1, type2] + }); + + await expect(client.find()).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + 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 mockRepository = {}; const mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [ `action:saved_objects/${type}/find` ], - username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ @@ -525,27 +676,36 @@ describe('#find', () => { expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const mockRepository = {}; + test(`returns result of callWithRequestRepository.find when legacy`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + find: jest.fn().mockReturnValue(returnValue) + }; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + result: HAS_PRIVILEGES_RESULT.LEGACY, + username, + missing: [ + `action:saved_objects/${type}/find` + ], + })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - repository: mockRepository, + callWithRequestRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, - savedObjectTypes: [type1, type2] + savedObjectTypes: [type] }); + const options = Symbol(); - await expect(client.find()).rejects.toThrowError(mockErrors.generalError); + const result = await client.find(options); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(result).toBe(returnValue); + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockRepository.find).toHaveBeenCalledWith(options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); @@ -558,7 +718,7 @@ describe('#find', () => { }; const mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, missing: [ `action:saved_objects/${type1}/find` ] @@ -566,7 +726,7 @@ describe('#find', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - repository: mockRepository, + internalRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, savedObjectTypes: [type1, type2] @@ -580,7 +740,7 @@ describe('#find', () => { })); }); - test(`calls and returns result of repository.find`, async () => { + test(`returns result of repository.find`, async () => { const type = 'foo'; const username = Symbol(); const returnValue = Symbol(); @@ -588,13 +748,13 @@ describe('#find', () => { find: jest.fn().mockReturnValue(returnValue) }; const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: true, - missing: [], + result: HAS_PRIVILEGES_RESULT.AUTHORIZED, username, + missing: [], })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - repository: mockRepository, + internalRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, savedObjectTypes: [type] @@ -614,17 +774,38 @@ describe('#find', () => { }); describe('#bulkGet', () => { - test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith(['action:saved_objects/foo/bulk_get']); + 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 mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [ `action:saved_objects/${type1}/bulk_get` ], - username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ @@ -654,28 +835,40 @@ describe('#bulkGet', () => { expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); + test(`returns result of internalRepository.bulkGet when authorized`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + bulkGet: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + username, + })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - errors: mockErrors, + internalRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); + const objects = [ + { type: type1, id: 'foo-id' }, + { type: type2, id: 'bar-id' }, + ]; - await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); + const result = await client.bulkGet(objects); - expect(mockHasPrivileges).toHaveBeenCalledWith(['action:saved_objects/foo/bulk_get']); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(result).toBe(returnValue); + expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { + objects, + }); }); - test(`calls and returns result of repository.bulkGet`, async () => { + test(`returns result of callWithRequestRepository.bulkGet when legacy`, async () => { const type1 = 'foo'; const type2 = 'bar'; const username = Symbol(); @@ -684,12 +877,16 @@ describe('#bulkGet', () => { bulkGet: jest.fn().mockReturnValue(returnValue) }; const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: true, + result: HAS_PRIVILEGES_RESULT.LEGACY, username, + missing: [ + `action:saved_objects/${type1}/bulk_get`, + `action:saved_objects/${type2}/bulk_get` + ], })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - repository: mockRepository, + callWithRequestRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); @@ -703,23 +900,42 @@ describe('#bulkGet', () => { expect(result).toBe(returnValue); expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { - objects, - }); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); }); describe('#get', () => { - test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/get`]); + 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 mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [ `action:saved_objects/${type}/get` ], - username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ @@ -746,28 +962,37 @@ describe('#get', () => { expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + test(`returns result of internalRepository.get when authorized`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + get: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + username, + })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - errors: mockErrors, + internalRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); + const id = Symbol(); - await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); + const result = await client.get(type, id); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/get`]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(result).toBe(returnValue); + expect(mockRepository.get).toHaveBeenCalledWith(type, id); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { + type, + id, + }); }); - test(`calls and returns result of repository.get`, async () => { + test(`returns result of callWithRequestRepository.get when user isn't authorized and has legacy fallback`, async () => { const type = 'foo'; const username = Symbol(); const returnValue = Symbol(); @@ -775,12 +1000,15 @@ describe('#get', () => { get: jest.fn().mockReturnValue(returnValue) }; const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: true, + result: HAS_PRIVILEGES_RESULT.LEGACY, username, + missing: [ + `action:saved_objects/${type}/get` + ], })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - repository: mockRepository, + callWithRequestRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); @@ -791,24 +1019,42 @@ describe('#get', () => { expect(result).toBe(returnValue); expect(mockRepository.get).toHaveBeenCalledWith(type, id); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { - type, - id, - }); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); }); describe('#update', () => { - test(`throws decorated ForbiddenError when user doesn't have privileges`, async () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockHasPrivileges = jest.fn().mockImplementation(async () => { + throw new Error(); + }); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClient({ + errors: mockErrors, + hasPrivileges: mockHasPrivileges, + auditLogger: mockAuditLogger, + }); + + await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/update`]); + 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 mockErrors = createMockErrors(); const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: false, + result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + username, missing: [ 'action:saved_objects/foo/update' ], - username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ @@ -839,28 +1085,41 @@ describe('#update', () => { expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + test(`returns result of repository.update when authorized`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); + const username = Symbol(); + const returnValue = Symbol(); + const mockRepository = { + update: jest.fn().mockReturnValue(returnValue) + }; + const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ + result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + username, + })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - errors: mockErrors, + internalRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); + const id = Symbol(); + const attributes = Symbol(); + const options = Symbol(); - await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); + const result = await client.update(type, id, attributes, options); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/update`]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(result).toBe(returnValue); + expect(mockRepository.update).toHaveBeenCalledWith(type, id, attributes, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'update', [type], { + type, + id, + attributes, + options, + }); }); - test(`calls and returns result of repository.update`, async () => { + test(`returns result of repository.update when legacy`, async () => { const type = 'foo'; const username = Symbol(); const returnValue = Symbol(); @@ -868,12 +1127,15 @@ describe('#update', () => { update: jest.fn().mockReturnValue(returnValue) }; const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - success: true, + result: HAS_PRIVILEGES_RESULT.LEGACY, username, + missing: [ + 'action:saved_objects/foo/update' + ], })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ - repository: mockRepository, + callWithRequestRepository: mockRepository, hasPrivileges: mockHasPrivileges, auditLogger: mockAuditLogger, }); @@ -886,11 +1148,6 @@ describe('#update', () => { expect(result).toBe(returnValue); expect(mockRepository.update).toHaveBeenCalledWith(type, id, attributes, options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'update', [type], { - type, - id, - attributes, - options, - }); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js b/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js index 12a25607a92acd..6089aec5d89d02 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js @@ -68,7 +68,7 @@ export default function ({ getService }) { }); }; - const expectForbidden = resp => { + const expectRbacForbidden = resp => { //eslint-disable-next-line max-len const missingActions = `action:login,action:saved_objects/config/bulk_get,action:saved_objects/dashboard/bulk_get,action:saved_objects/visualization/bulk_get`; expect(resp.body).to.eql({ @@ -102,7 +102,7 @@ export default function ({ getService }) { tests: { default: { statusCode: 403, - response: expectForbidden, + response: expectRbacForbidden, } } }); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/create.js b/x-pack/test/rbac_api_integration/apis/saved_objects/create.js index c8ca5be09b6ad4..cff5da3502838c 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/create.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/create.js @@ -29,7 +29,7 @@ export default function ({ getService }) { }); }; - const createExpectForbidden = canLogin => resp => { + const createExpectRbacForbidden = canLogin => resp => { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', @@ -37,6 +37,15 @@ export default function ({ getService }) { }); }; + const createExpectLegacyForbidden = username => resp => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + //eslint-disable-next-line max-len + message: `action [indices:data/write/index] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/index] is unauthorized for user [${username}]` + }); + }; + const createTest = (description, { auth, tests }) => { describe(description, () => { before(() => esArchiver.load('saved_objects/basic')); @@ -64,7 +73,7 @@ export default function ({ getService }) { tests: { default: { statusCode: 403, - response: createExpectForbidden(false), + response: createExpectRbacForbidden(false), }, } }); @@ -103,7 +112,7 @@ export default function ({ getService }) { tests: { default: { statusCode: 403, - response: createExpectForbidden(true), + response: createExpectLegacyForbidden(AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME), }, } }); @@ -129,7 +138,7 @@ export default function ({ getService }) { tests: { default: { statusCode: 403, - response: createExpectForbidden(true), + response: createExpectRbacForbidden(true), }, } }); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js b/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js index d6bd0d61dd646a..b75ab5342c6baa 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js @@ -25,7 +25,7 @@ export default function ({ getService }) { }); }; - const createExpectForbidden = canLogin => resp => { + const createExpectRbacForbidden = canLogin => resp => { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', @@ -33,6 +33,15 @@ export default function ({ getService }) { }); }; + const createExpectLegacyForbidden = username => resp => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + //eslint-disable-next-line max-len + message: `action [indices:data/write/delete] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/delete] is unauthorized for user [${username}]` + }); + }; + const deleteTest = (description, { auth, tests }) => { describe(description, () => { before(() => esArchiver.load('saved_objects/basic')); @@ -64,11 +73,11 @@ export default function ({ getService }) { tests: { actualId: { statusCode: 403, - response: createExpectForbidden(false), + response: createExpectRbacForbidden(false), }, invalidId: { statusCode: 403, - response: createExpectForbidden(false), + response: createExpectRbacForbidden(false), } } }); @@ -115,11 +124,11 @@ export default function ({ getService }) { tests: { actualId: { statusCode: 403, - response: createExpectForbidden(true), + response: createExpectLegacyForbidden(AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME), }, invalidId: { statusCode: 403, - response: createExpectForbidden(true), + response: createExpectLegacyForbidden(AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME), } } }); @@ -149,11 +158,11 @@ export default function ({ getService }) { tests: { actualId: { statusCode: 403, - response: createExpectForbidden(true), + response: createExpectRbacForbidden(true), }, invalidId: { statusCode: 403, - response: createExpectForbidden(true), + response: createExpectRbacForbidden(true), } } }); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/find.js b/x-pack/test/rbac_api_integration/apis/saved_objects/find.js index 59cc3dc12d5389..0498021e5daae2 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/find.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/find.js @@ -31,7 +31,7 @@ export default function ({ getService }) { }); }; - const expectAllResults = (resp) => { + const expectResultsWithValidTypes = (resp) => { expect(resp.body).to.eql({ page: 1, per_page: 20, @@ -69,6 +69,50 @@ export default function ({ getService }) { }); }; + const expectAllResultsIncludingInvalidTypes = (resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 5, + saved_objects: [ + { + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + type: 'index-pattern', + updated_at: '2017-09-21T18:49:16.270Z', + version: 1, + attributes: resp.body.saved_objects[0].attributes + }, + { + id: '7.0.0-alpha1', + type: 'config', + updated_at: '2017-09-21T18:49:16.302Z', + version: 1, + attributes: resp.body.saved_objects[1].attributes + }, + { + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + type: 'visualization', + updated_at: '2017-09-21T18:51:23.794Z', + version: 1, + attributes: resp.body.saved_objects[2].attributes + }, + { + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + type: 'dashboard', + updated_at: '2017-09-21T18:57:40.826Z', + version: 1, + attributes: resp.body.saved_objects[3].attributes + }, + { + id: 'visualization:dd7caf20-9efd-11e7-acb3-3dab96693faa', + type: 'not-a-visualization', + updated_at: '2017-09-21T18:51:23.794Z', + version: 1 + }, + ] + }); + }; + const createExpectEmpty = (page, perPage, total) => (resp) => { expect(resp.body).to.eql({ page: page, @@ -78,7 +122,7 @@ export default function ({ getService }) { }); }; - const createExpectActionForbidden = (canLogin, type) => resp => { + const createExpectRbacForbidden = (canLogin, type) => resp => { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', @@ -158,22 +202,22 @@ export default function ({ getService }) { normal: { description: 'forbidden login and find visualization message', statusCode: 403, - response: createExpectActionForbidden(false, 'visualization'), + response: createExpectRbacForbidden(false, 'visualization'), }, unknownType: { description: 'forbidden login and find wigwags message', statusCode: 403, - response: createExpectActionForbidden(false, 'wigwags'), + response: createExpectRbacForbidden(false, 'wigwags'), }, pageBeyondTotal: { description: 'forbidden login and find visualization message', statusCode: 403, - response: createExpectActionForbidden(false, 'visualization'), + response: createExpectRbacForbidden(false, 'visualization'), }, unknownSearchField: { description: 'forbidden login and find wigwags message', statusCode: 403, - response: createExpectActionForbidden(false, 'wigwags'), + response: createExpectRbacForbidden(false, 'wigwags'), }, noType: { description: `forbidded can't find any types`, @@ -212,7 +256,7 @@ export default function ({ getService }) { noType: { description: 'all objects', statusCode: 200, - response: expectAllResults, + response: expectResultsWithValidTypes, }, }, }); @@ -246,7 +290,7 @@ export default function ({ getService }) { noType: { description: 'all objects', statusCode: 200, - response: expectAllResults, + response: expectAllResultsIncludingInvalidTypes, }, }, }); @@ -263,9 +307,9 @@ export default function ({ getService }) { response: expectVisualizationResults, }, unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectActionForbidden(true, 'wigwags'), + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), }, pageBeyondTotal: { description: 'empty result', @@ -273,14 +317,14 @@ export default function ({ getService }) { response: createExpectEmpty(100, 100, 1), }, unknownSearchField: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectActionForbidden(true, 'wigwags'), + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), }, noType: { description: 'all objects', statusCode: 200, - response: expectAllResults, + response: expectAllResultsIncludingInvalidTypes, }, } }); @@ -314,7 +358,7 @@ export default function ({ getService }) { noType: { description: 'all objects', statusCode: 200, - response: expectAllResults, + response: expectResultsWithValidTypes, }, }, }); @@ -333,7 +377,7 @@ export default function ({ getService }) { unknownType: { description: 'forbidden find wigwags message', statusCode: 403, - response: createExpectActionForbidden(true, 'wigwags'), + response: createExpectRbacForbidden(true, 'wigwags'), }, pageBeyondTotal: { description: 'empty result', @@ -343,12 +387,12 @@ export default function ({ getService }) { unknownSearchField: { description: 'forbidden find wigwags message', statusCode: 403, - response: createExpectActionForbidden(true, 'wigwags'), + response: createExpectRbacForbidden(true, 'wigwags'), }, noType: { description: 'all objects', statusCode: 200, - response: expectAllResults, + response: expectResultsWithValidTypes, }, } }); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/get.js b/x-pack/test/rbac_api_integration/apis/saved_objects/get.js index e055d9ef75e483..ac7cc6b70f50a8 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/get.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/get.js @@ -39,7 +39,7 @@ export default function ({ getService }) { }); }; - const expectForbidden = resp => { + const expectRbacForbidden = resp => { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', @@ -80,11 +80,11 @@ export default function ({ getService }) { tests: { exists: { statusCode: 403, - response: expectForbidden, + response: expectRbacForbidden, }, doesntExist: { statusCode: 403, - response: expectForbidden, + response: expectRbacForbidden, }, } }); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/update.js b/x-pack/test/rbac_api_integration/apis/saved_objects/update.js index e3740ede1aaafb..edcec1ffb61246 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/update.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/update.js @@ -38,7 +38,7 @@ export default function ({ getService }) { }); }; - const createExpectForbidden = canLogin => resp => { + const createExpectRbacForbidden = canLogin => resp => { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', @@ -46,6 +46,15 @@ export default function ({ getService }) { }); }; + const createExpectLegacyForbidden = username => resp => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + //eslint-disable-next-line max-len + message: `action [indices:data/write/update] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/update] is unauthorized for user [${username}]` + }); + }; + const updateTest = (description, { auth, tests }) => { describe(description, () => { before(() => esArchiver.load('saved_objects/basic')); @@ -88,11 +97,11 @@ export default function ({ getService }) { tests: { exists: { statusCode: 403, - response: createExpectForbidden(false), + response: createExpectRbacForbidden(false), }, doesntExist: { statusCode: 403, - response: createExpectForbidden(false), + response: createExpectRbacForbidden(false), }, } }); @@ -139,11 +148,11 @@ export default function ({ getService }) { tests: { exists: { statusCode: 403, - response: createExpectForbidden(true), + response: createExpectLegacyForbidden(AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME), }, doesntExist: { statusCode: 403, - response: createExpectForbidden(true), + response: createExpectLegacyForbidden(AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME), }, } }); @@ -173,11 +182,11 @@ export default function ({ getService }) { tests: { exists: { statusCode: 403, - response: createExpectForbidden(true), + response: createExpectRbacForbidden(true), }, doesntExist: { statusCode: 403, - response: createExpectForbidden(true), + response: createExpectRbacForbidden(true), }, } }); From b8a110b0cca42ac67c559ba8d99e600b50781178 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Mon, 2 Jul 2018 07:28:00 -0400 Subject: [PATCH 059/183] Setting the status to red on the first error then continually (#20343) initializing --- .../watch_status_and_license_to_initialize.js | 42 ++++++++++--------- ...h_status_and_license_to_initialize.test.js | 41 +++++++++++++----- 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js index c0f54b8b2fbf1f..28cbbbb04c93d4 100644 --- a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js +++ b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js @@ -4,23 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ import * as Rx from 'rxjs'; -import { catchError, mergeMap, map, retryWhen, switchMap, tap } from 'rxjs/operators'; +import { catchError, mergeMap, map, switchMap, tap } from 'rxjs/operators'; -export const retryStrategy = ({ - maxAttempts, - scalingDuration, -}) => (errors) => { - return errors.pipe( - mergeMap((error, i) => { - const attempt = i + 1; +export const RETRY_SCALE_DURATION = 100; +export const RETRY_DURATION_MAX = 10000; - if (attempt >= maxAttempts) { - return Rx.throwError(error); - } +const calculateDuration = i => { + const duration = i * RETRY_SCALE_DURATION; + if (duration > RETRY_DURATION_MAX) { + return RETRY_DURATION_MAX; + } - return Rx.timer(attempt * scalingDuration); - }) - ); + return duration; +}; + +// we can't use a retryWhen here, because we want to propagate the red status and then retry +const propagateRedStatusAndScaleRetry = () => { + let i = 0; + return (err, caught) => + Rx.concat( + Rx.of({ + state: 'red', + message: err.message + }), + Rx.timer(calculateDuration(++i)).pipe(mergeMap(() => caught)) + ); }; export function watchStatusAndLicenseToInitialize(xpackMainPlugin, downstreamPlugin, initialize) { @@ -58,15 +66,11 @@ export function watchStatusAndLicenseToInitialize(xpackMainPlugin, downstreamPlu return Rx.defer(() => initialize(license)) .pipe( - retryWhen(retryStrategy({ maxAttempts: 20, scalingDuration: 100 })), map(() => ({ state: 'green', message: 'Ready', })), - catchError(err => Rx.of({ - state: 'red', - message: err.message - })) + catchError(propagateRedStatusAndScaleRetry()) ); }), tap(({ state, message }) => { diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js index 6964255c535610..d56598b9d01111 100644 --- a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js +++ b/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js @@ -5,7 +5,7 @@ */ import { EventEmitter } from 'events'; -import { watchStatusAndLicenseToInitialize } from './watch_status_and_license_to_initialize'; +import { watchStatusAndLicenseToInitialize, RETRY_SCALE_DURATION, RETRY_DURATION_MAX } from './watch_status_and_license_to_initialize'; const createMockXpackMainPluginAndFeature = (featureId) => { const licenseChangeCallbacks = []; @@ -62,6 +62,15 @@ const createMockDownstreamPlugin = (id) => { }; }; +const advanceRetry = async (initializeCount) => { + await Promise.resolve(); + let duration = initializeCount * RETRY_SCALE_DURATION; + if (duration > RETRY_DURATION_MAX) { + duration = RETRY_DURATION_MAX; + } + jest.advanceTimersByTime(duration); +}; + ['red', 'yellow', 'disabled'].forEach(state => { test(`mirrors ${state} immediately`, () => { const pluginId = 'foo-plugin'; @@ -115,7 +124,7 @@ test(`sets downstream plugin's status to green when initialize resolves`, (done) }); }); -test(`sets downstream plugin's status to red when initialize rejects 20 times`, (done) => { +test(`sets downstream plugin's status to red when initialize initially rejects, and continually polls initialize`, (done) => { jest.useFakeTimers(); const pluginId = 'foo-plugin'; @@ -126,15 +135,25 @@ test(`sets downstream plugin's status to red when initialize rejects 20 times`, mockFeature.mock.setLicenseCheckResults(licenseCheckResults); const downstreamPlugin = createMockDownstreamPlugin(pluginId); + let isRed = false; let initializeCount = 0; const initializeMock = jest.fn().mockImplementation(() => { ++initializeCount; + // on the second retry, ensure we already set the status to red + if (initializeCount === 2) { + expect(isRed).toBe(true); + } + + // this should theoretically continue indefinitely, but we only have so long to run the tests + if (initializeCount === 100) { + done(); + } + // everytime this is called, we have to wait for a new promise to be resolved // allowing the Promise the we return below to run, and then advance the timers - setImmediate(async () => { - await Promise.resolve(); - jest.advanceTimersByTime(initializeCount * 100); + setImmediate(() => { + advanceRetry(initializeCount); }); return Promise.reject(new Error(errorMessage)); }); @@ -144,9 +163,8 @@ test(`sets downstream plugin's status to red when initialize rejects 20 times`, expect(initializeMock).toHaveBeenCalledTimes(1); expect(initializeMock).toHaveBeenCalledWith(licenseCheckResults); downstreamPlugin.status.red.mockImplementation(message => { - expect(initializeCount).toBe(20); + isRed = true; expect(message).toBe(errorMessage); - done(); }); }); @@ -167,9 +185,8 @@ test(`sets downstream plugin's status to green when initialize resolves after re // everytime this is called, we have to wait for a new promise to be resolved // allowing the Promise the we return below to run, and then advance the timers - setImmediate(async () => { - await Promise.resolve(); - jest.advanceTimersByTime(initializeCount * 100); + setImmediate(() => { + advanceRetry(initializeCount); }); if (initializeCount >= 10) { @@ -183,6 +200,10 @@ test(`sets downstream plugin's status to green when initialize resolves after re expect(initializeMock).toHaveBeenCalledTimes(1); expect(initializeMock).toHaveBeenCalledWith(licenseCheckResults); + downstreamPlugin.status.red.mockImplementation(message => { + expect(initializeCount).toBeLessThan(10); + expect(message).toBe(errorMessage); + }); downstreamPlugin.status.green.mockImplementation(message => { expect(initializeCount).toBe(10); expect(message).toBe('Ready'); From 8696030258c5b9c3afddd42247b0f23b7ec414a0 Mon Sep 17 00:00:00 2001 From: kobelb Date: Mon, 2 Jul 2018 09:37:57 -0400 Subject: [PATCH 060/183] Renaming get*Privilege to get*Action --- .../lib/authorization/has_privileges.js | 14 +++--- .../lib/authorization/has_privileges.test.js | 34 +++++++------- .../security/server/lib/privileges/index.js | 2 +- .../server/lib/privileges/privileges.js | 46 +++++++++---------- 4 files changed, 46 insertions(+), 50 deletions(-) diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.js index 2f68c7fadaf95d..224ba8c44ec841 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.js @@ -6,7 +6,7 @@ import { getClient } from '../../../../../server/lib/get_client_shield'; import { DEFAULT_RESOURCE } from '../../../common/constants'; -import { getVersionPrivilege, getLoginPrivilege } from '../privileges'; +import { getVersionAction, getLoginAction } from '../privileges'; export const HAS_PRIVILEGES_RESULT = { UNAUTHORIZED: Symbol(), @@ -22,8 +22,8 @@ export function hasPrivilegesWithServer(server) { const application = config.get('xpack.security.rbac.application'); const kibanaIndex = config.get('kibana.index'); - const loginPrivilege = getLoginPrivilege(); - const versionPrivilege = getVersionPrivilege(kibanaVersion); + const loginAction = getLoginAction(); + const versionAction = getVersionAction(kibanaVersion); return function hasPrivilegesWithRequest(request) { @@ -43,7 +43,7 @@ export function hasPrivilegesWithServer(server) { // We include the login action in all privileges, so the existence of it and not the version privilege // lets us know that we're running in an incorrect configuration. Without the login privilege check, we wouldn't // know whether the user just wasn't authorized for this instance of Kibana in general - if (!hasPrivileges[getVersionPrivilege(kibanaVersion)] && hasPrivileges[getLoginPrivilege()]) { + if (!hasPrivileges[getVersionAction(kibanaVersion)] && hasPrivileges[getLoginAction()]) { throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.'); } @@ -68,7 +68,7 @@ export function hasPrivilegesWithServer(server) { }; return async function hasPrivileges(privileges) { - const allPrivileges = [versionPrivilege, loginPrivilege, ...privileges]; + const allPrivileges = [versionAction, loginAction, ...privileges]; const privilegesCheck = await hasApplicationPrivileges(allPrivileges); const username = privilegesCheck.username; @@ -76,7 +76,7 @@ export function hasPrivilegesWithServer(server) { // We don't want to expose the version privilege to consumers, as it's an implementation detail only to detect version mismatch const missing = Object.keys(privilegesCheck.privileges) .filter(p => !privilegesCheck.privileges[p]) - .filter(p => p !== versionPrivilege); + .filter(p => p !== versionAction); if (privilegesCheck.hasAllRequested) { return { @@ -86,7 +86,7 @@ export function hasPrivilegesWithServer(server) { }; } - if (!privilegesCheck.privileges[loginPrivilege] && await hasPrivilegesOnKibanaIndex()) { + if (!privilegesCheck.privileges[loginAction] && await hasPrivilegesOnKibanaIndex()) { const msg = `Relying on implicit privileges determined from the index privileges is deprecated and will be removed in Kibana 7.0`; server.log(['warning', 'deprecated', 'security'], msg); diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js index 7f38ba3965b786..5942e0058691b9 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js @@ -7,7 +7,7 @@ import { hasPrivilegesWithServer, HAS_PRIVILEGES_RESULT } from './has_privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; import { DEFAULT_RESOURCE } from '../../../common/constants'; -import { getLoginPrivilege, getVersionPrivilege } from '../privileges'; +import { getLoginAction, getVersionAction } from '../privileges'; jest.mock('../../../../../server/lib/get_client_shield', () => ({ getClient: jest.fn() @@ -92,8 +92,8 @@ test(`returns authorized if they have all application privileges`, async () => { mockApplicationPrivilegeResponse({ hasAllRequested: true, privileges: { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, + [getVersionAction(defaultVersion)]: true, + [getLoginAction()]: true, [privilege]: true, }, application: defaultApplication, @@ -114,7 +114,7 @@ test(`returns authorized if they have all application privileges`, async () => { application: defaultApplication, resources: [DEFAULT_RESOURCE], privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + getVersionAction(defaultVersion), getLoginAction(), ...privileges ] }] } @@ -135,8 +135,8 @@ test(`returns unauthorized they have only one application privilege`, async () = mockApplicationPrivilegeResponse({ hasAllRequested: false, privileges: { - [getVersionPrivilege(defaultVersion)]: true, - [getLoginPrivilege()]: true, + [getVersionAction(defaultVersion)]: true, + [getLoginAction()]: true, [privilege1]: true, [privilege2]: false, }, @@ -158,7 +158,7 @@ test(`returns unauthorized they have only one application privilege`, async () = application: defaultApplication, resources: [DEFAULT_RESOURCE], privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + getVersionAction(defaultVersion), getLoginAction(), ...privileges ] }] } @@ -177,8 +177,8 @@ test(`throws error if missing version privilege and has login privilege`, async mockApplicationPrivilegeResponse({ hasAllRequested: false, privileges: { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: true, + [getVersionAction(defaultVersion)]: false, + [getLoginAction()]: true, [privilege]: true, } }) @@ -200,8 +200,8 @@ describe('legacy fallback with no application privileges', () => { mockApplicationPrivilegeResponse({ hasAllRequested: false, privileges: { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: false, + [getVersionAction(defaultVersion)]: false, + [getLoginAction()]: false, [privilege]: false, }, username, @@ -229,7 +229,7 @@ describe('legacy fallback with no application privileges', () => { application: defaultApplication, resources: [DEFAULT_RESOURCE], privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + getVersionAction(defaultVersion), getLoginAction(), ...privileges ] }] } @@ -245,7 +245,7 @@ describe('legacy fallback with no application privileges', () => { expect(result).toEqual({ result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, username, - missing: [getLoginPrivilege(), ...privileges], + missing: [getLoginAction(), ...privileges], }); }); @@ -258,8 +258,8 @@ describe('legacy fallback with no application privileges', () => { mockApplicationPrivilegeResponse({ hasAllRequested: false, privileges: { - [getVersionPrivilege(defaultVersion)]: false, - [getLoginPrivilege()]: false, + [getVersionAction(defaultVersion)]: false, + [getLoginAction()]: false, [privilege]: false, }, username, @@ -288,7 +288,7 @@ describe('legacy fallback with no application privileges', () => { application: defaultApplication, resources: [DEFAULT_RESOURCE], privileges: [ - getVersionPrivilege(defaultVersion), getLoginPrivilege(), ...privileges + getVersionAction(defaultVersion), getLoginAction(), ...privileges ] }] } @@ -304,7 +304,7 @@ describe('legacy fallback with no application privileges', () => { expect(result).toEqual({ result: HAS_PRIVILEGES_RESULT.LEGACY, username, - missing: [getLoginPrivilege(), ...privileges], + missing: [getLoginAction(), ...privileges], }); }); }); diff --git a/x-pack/plugins/security/server/lib/privileges/index.js b/x-pack/plugins/security/server/lib/privileges/index.js index a7a2455d5ec3b0..f888dffa922dd6 100644 --- a/x-pack/plugins/security/server/lib/privileges/index.js +++ b/x-pack/plugins/security/server/lib/privileges/index.js @@ -5,4 +5,4 @@ */ export { registerPrivilegesWithCluster } from './privilege_action_registry'; -export { buildPrivilegeMap, getLoginPrivilege, getVersionPrivilege } from './privileges'; +export { buildPrivilegeMap, getLoginAction, getVersionAction } from './privileges'; diff --git a/x-pack/plugins/security/server/lib/privileges/privileges.js b/x-pack/plugins/security/server/lib/privileges/privileges.js index 96600013887944..ea22f6e276594f 100644 --- a/x-pack/plugins/security/server/lib/privileges/privileges.js +++ b/x-pack/plugins/security/server/lib/privileges/privileges.js @@ -4,43 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -export function getVersionPrivilege(kibanaVersion) { - // TODO: Remove the .toLowerCase() once the capitalization with app privileges is fixed - return `version:${kibanaVersion.toLowerCase()}`; +export function getVersionAction(kibanaVersion) { + return `version:${kibanaVersion}`; } -export function getLoginPrivilege() { +export function getLoginAction() { return `action:login`; } export function buildPrivilegeMap(savedObjectTypes, application, kibanaVersion) { - const readSavedObjectsPrivileges = buildSavedObjectsReadPrivileges(savedObjectTypes); - - const privilegeActions = {}; - - privilegeActions.all = { - application, - name: 'all', - actions: [getVersionPrivilege(kibanaVersion), 'action:*'], - metadata: {} + const readSavedObjectsActions = buildSavedObjectsReadActions(savedObjectTypes); + + return { + all: { + application, + name: 'all', + actions: [getVersionAction(kibanaVersion), 'action:*'], + metadata: {} + }, + read: { + application, + name: 'read', + actions: [getVersionAction(kibanaVersion), getLoginAction(), ...readSavedObjectsActions], + metadata: {} + } }; - - privilegeActions.read = { - application, - name: 'read', - actions: [getVersionPrivilege(kibanaVersion), getLoginPrivilege(), ...readSavedObjectsPrivileges], - metadata: {} - }; - - return privilegeActions; } -function buildSavedObjectsReadPrivileges(savedObjectTypes) { +function buildSavedObjectsReadActions(savedObjectTypes) { const readActions = ['get', 'bulk_get', 'find']; - return buildSavedObjectsPrivileges(savedObjectTypes, readActions); + return buildSavedObjectsActions(savedObjectTypes, readActions); } -function buildSavedObjectsPrivileges(savedObjectTypes, actions) { +function buildSavedObjectsActions(savedObjectTypes, actions) { return savedObjectTypes .map(type => actions.map(action => `action:saved_objects/${type}/${action}`)) .reduce((acc, types) => [...acc, ...types], []); From a56af65f44d476c90cb2dacb36267a9a8b1140d3 Mon Sep 17 00:00:00 2001 From: kobelb Date: Mon, 2 Jul 2018 09:46:48 -0400 Subject: [PATCH 061/183] Adding "instance" to alert about other application privileges --- x-pack/plugins/security/public/views/management/edit_role.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security/public/views/management/edit_role.html b/x-pack/plugins/security/public/views/management/edit_role.html index da03371ae6cbd4..3b5c7a13412a74 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.html +++ b/x-pack/plugins/security/public/views/management/edit_role.html @@ -47,7 +47,7 @@

This role contains application privileges for the {{ otherApplications.join(', ') }} application(s) that can't be edited. - If they are for other instances of Kibana, you must manage those privileges on that Kibana. + If they are for other instances of Kibana, you must manage those privileges on that Kibana instance.

From 19a7d6f32bc7c3b531d969c8573e4ead231589c8 Mon Sep 17 00:00:00 2001 From: kobelb Date: Mon, 2 Jul 2018 10:40:10 -0400 Subject: [PATCH 062/183] Revising some of the naming for the edit roles screen --- .../public/services/application_privilege.js | 2 +- .../public/views/management/edit_role.js | 59 ++++++++++--------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/security/public/services/application_privilege.js b/x-pack/plugins/security/public/services/application_privilege.js index 91e8d52a84a726..615188cad33d7f 100644 --- a/x-pack/plugins/security/public/services/application_privilege.js +++ b/x-pack/plugins/security/public/services/application_privilege.js @@ -8,7 +8,7 @@ import 'angular-resource'; import { uiModules } from 'ui/modules'; const module = uiModules.get('security', ['ngResource']); -module.service('ApplicationPrivilege', ($resource, chrome) => { +module.service('ApplicationPrivileges', ($resource, chrome) => { const baseUrl = chrome.addBasePath('/api/security/v1/privileges'); return $resource(baseUrl); }); diff --git a/x-pack/plugins/security/public/views/management/edit_role.js b/x-pack/plugins/security/public/views/management/edit_role.js index 94e04a30a37db5..ead331647e7f44 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.js +++ b/x-pack/plugins/security/public/views/management/edit_role.js @@ -24,57 +24,58 @@ import { checkLicenseError } from 'plugins/security/lib/check_license_error'; import { EDIT_ROLES_PATH, ROLES_PATH } from './management_urls'; import { DEFAULT_RESOURCE } from '../../../common/constants'; -const getKibanaPrivileges = (kibanaApplicationPrivilege, role, application) => { - const kibanaPrivileges = kibanaApplicationPrivilege.reduce((acc, p) => { +const getKibanaPrivileges = (kibanaApplicationPrivileges, roleApplications, application) => { + const kibanaPrivileges = kibanaApplicationPrivileges.reduce((acc, p) => { acc[p.name] = false; return acc; }, {}); - if (!role.applications || role.applications.length === 0) { + if (!roleApplications || roleApplications.length === 0) { return kibanaPrivileges; } // we're filtering out privileges for non-default resources as well incase // the roles were created in a future version - const applications = role.applications - .filter(x => x.application === application && x.resources.every(r => r === DEFAULT_RESOURCE)); + const applications = roleApplications + .filter(roleApplication => roleApplication.application === application && roleApplication.resources.every(r => r === DEFAULT_RESOURCE)); const assigned = _.uniq(_.flatten(_.pluck(applications, 'privileges'))); assigned.forEach(a => { - kibanaPrivileges[a] = true; + // we don't want to display privileges that aren't in our expected list of privileges + if (a in kibanaPrivileges) { + kibanaPrivileges[a] = true; + } }); return kibanaPrivileges; }; -const setApplicationPrivileges = (kibanaPrivileges, role, application) => { - if (!role.applications) { - role.applications = []; - } - - // we first remove the matching application entries - role.applications = role.applications.filter(x => { - return x.application !== application; +const getRoleApplications = (kibanaPrivileges, currentRoleApplications = [], application) => { + // we keep any other applications + const newRoleApplications = currentRoleApplications.filter(roleApplication => { + return roleApplication.application !== application; }); - const privileges = Object.keys(kibanaPrivileges).filter(key => kibanaPrivileges[key]); + const selectedPrivileges = Object.keys(kibanaPrivileges).filter(key => kibanaPrivileges[key]); - // if we still have them, put the application entry back - if (privileges.length > 0) { - role.applications = [...role.applications, { + // if we have any selected privileges, add a single application entry + if (selectedPrivileges.length > 0) { + newRoleApplications.push({ application, - privileges, + privileges: selectedPrivileges, resources: [DEFAULT_RESOURCE] - }]; + }); } + + return newRoleApplications; }; -const getOtherApplications = (kibanaPrivileges, role, application) => { - if (!role.applications || role.applications.length === 0) { +const getOtherApplications = (roleApplications, application) => { + if (!roleApplications || roleApplications.length === 0) { return []; } - return role.applications.map(x => x.application).filter(x => x !== application); + return roleApplications.map(roleApplication => roleApplication.application).filter(app =>app !== application); }; routes.when(`${EDIT_ROLES_PATH}/:name?`, { @@ -103,8 +104,8 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { applications: [] }); }, - kibanaApplicationPrivilege(ApplicationPrivilege, kbnUrl, Promise, Private) { - return ApplicationPrivilege.query().$promise + kibanaApplicationPrivileges(ApplicationPrivileges, kbnUrl, Promise, Private) { + return ApplicationPrivileges.query().$promise .catch(checkLicenseError(kbnUrl, Promise, Private)); }, users(ShieldUser, kbnUrl, Promise, Private) { @@ -134,10 +135,10 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { $scope.indexPatterns = $route.current.locals.indexPatterns; $scope.privileges = shieldPrivileges; - const kibanaApplicationPrivilege = $route.current.locals.kibanaApplicationPrivilege; + const kibanaApplicationPrivileges = $route.current.locals.kibanaApplicationPrivileges; const role = $route.current.locals.role; - $scope.kibanaPrivileges = getKibanaPrivileges(kibanaApplicationPrivilege, role, rbacApplication); - $scope.otherApplications = getOtherApplications(kibanaApplicationPrivilege, role, rbacApplication); + $scope.kibanaPrivileges = getKibanaPrivileges(kibanaApplicationPrivileges, role.applications, rbacApplication); + $scope.otherApplications = getOtherApplications(role.applications, rbacApplication); $scope.rolesHref = `#${ROLES_PATH}`; @@ -164,7 +165,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { role.indices = role.indices.filter((index) => index.names.length); role.indices.forEach((index) => index.query || delete index.query); - setApplicationPrivileges($scope.kibanaPrivileges, role, rbacApplication); + role.applications = getRoleApplications($scope.kibanaPrivileges, role.applications, rbacApplication); return role.$save() .then(() => toastNotifications.addSuccess('Updated role')) From 33a153dd8a29b329b6e92733d0add897c90c9c02 Mon Sep 17 00:00:00 2001 From: kobelb Date: Mon, 2 Jul 2018 10:46:06 -0400 Subject: [PATCH 063/183] One more edit role variable renamed --- .../security/public/views/management/edit_role.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security/public/views/management/edit_role.js b/x-pack/plugins/security/public/views/management/edit_role.js index ead331647e7f44..49caf22228fee6 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.js +++ b/x-pack/plugins/security/public/views/management/edit_role.js @@ -24,8 +24,8 @@ import { checkLicenseError } from 'plugins/security/lib/check_license_error'; import { EDIT_ROLES_PATH, ROLES_PATH } from './management_urls'; import { DEFAULT_RESOURCE } from '../../../common/constants'; -const getKibanaPrivileges = (kibanaApplicationPrivileges, roleApplications, application) => { - const kibanaPrivileges = kibanaApplicationPrivileges.reduce((acc, p) => { +const getKibanaPrivileges = (applicationPrivileges, roleApplications, application) => { + const kibanaPrivileges = applicationPrivileges.reduce((acc, p) => { acc[p.name] = false; return acc; }, {}); @@ -104,7 +104,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { applications: [] }); }, - kibanaApplicationPrivileges(ApplicationPrivileges, kbnUrl, Promise, Private) { + applicationPrivileges(ApplicationPrivileges, kbnUrl, Promise, Private) { return ApplicationPrivileges.query().$promise .catch(checkLicenseError(kbnUrl, Promise, Private)); }, @@ -135,9 +135,9 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { $scope.indexPatterns = $route.current.locals.indexPatterns; $scope.privileges = shieldPrivileges; - const kibanaApplicationPrivileges = $route.current.locals.kibanaApplicationPrivileges; + const applicationPrivileges = $route.current.locals.applicationPrivileges; const role = $route.current.locals.role; - $scope.kibanaPrivileges = getKibanaPrivileges(kibanaApplicationPrivileges, role.applications, rbacApplication); + $scope.kibanaPrivileges = getKibanaPrivileges(applicationPrivileges, role.applications, rbacApplication); $scope.otherApplications = getOtherApplications(role.applications, rbacApplication); $scope.rolesHref = `#${ROLES_PATH}`; From 5d8745fe2640d0d7aa75fc567b172da5f424e649 Mon Sep 17 00:00:00 2001 From: kobelb Date: Mon, 2 Jul 2018 12:29:33 -0400 Subject: [PATCH 064/183] hasPrivileges is now checkPrivileges --- x-pack/plugins/security/index.js | 8 +- ....js.snap => check_privileges.test.js.snap} | 0 ...{has_privileges.js => check_privileges.js} | 30 +-- ...leges.test.js => check_privileges.test.js} | 46 ++-- .../secure_saved_objects_client.js | 22 +- .../secure_saved_objects_client.test.js | 236 +++++++++--------- 6 files changed, 171 insertions(+), 171 deletions(-) rename x-pack/plugins/security/server/lib/authorization/__snapshots__/{has_privileges.test.js.snap => check_privileges.test.js.snap} (100%) rename x-pack/plugins/security/server/lib/authorization/{has_privileges.js => check_privileges.js} (74%) rename x-pack/plugins/security/server/lib/authorization/{has_privileges.test.js => check_privileges.test.js} (84%) diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index c72ebfa74e1c04..6bcaf4e7aec46f 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -17,7 +17,7 @@ import { authenticateFactory } from './server/lib/auth_redirect'; import { checkLicense } from './server/lib/check_license'; import { initAuthenticator } from './server/lib/authentication/authenticator'; import { initPrivilegesApi } from './server/routes/api/v1/privileges'; -import { hasPrivilegesWithServer } from './server/lib/authorization/has_privileges'; +import { checkPrivilegesWithRequestFactory } from './server/lib/authorization/check_privileges'; import { SecurityAuditLogger } from './server/lib/audit_logger'; import { AuditLogger } from '../../server/lib/audit_logger'; import { SecureSavedObjectsClient } from './server/lib/saved_objects_client/secure_saved_objects_client'; @@ -114,7 +114,7 @@ export const security = (kibana) => new kibana.Plugin({ server.auth.strategy('session', 'login', 'required'); const auditLogger = new SecurityAuditLogger(server.config(), new AuditLogger(server, 'security')); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(server); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(server); const { savedObjects } = server; savedObjects.setScopedSavedObjectsClientFactory(({ @@ -130,14 +130,14 @@ export const security = (kibana) => new kibana.Plugin({ return new savedObjects.SavedObjectsClient(callWithRequestRepository); } - const hasPrivileges = hasPrivilegesWithRequest(request); + const checkPrivileges = checkPrivilegesWithRequest(request); const internalRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser); return new SecureSavedObjectsClient({ internalRepository, callWithRequestRepository, errors: savedObjects.SavedObjectsClient.errors, - hasPrivileges, + checkPrivileges, auditLogger, savedObjectTypes: savedObjects.types, }); diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/has_privileges.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap similarity index 100% rename from x-pack/plugins/security/server/lib/authorization/__snapshots__/has_privileges.test.js.snap rename to x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.js similarity index 74% rename from x-pack/plugins/security/server/lib/authorization/has_privileges.js rename to x-pack/plugins/security/server/lib/authorization/check_privileges.js index 224ba8c44ec841..2ccc0f3e6e0ce9 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.js @@ -8,13 +8,13 @@ import { getClient } from '../../../../../server/lib/get_client_shield'; import { DEFAULT_RESOURCE } from '../../../common/constants'; import { getVersionAction, getLoginAction } from '../privileges'; -export const HAS_PRIVILEGES_RESULT = { +export const CHECK_PRIVILEGES_RESULT = { UNAUTHORIZED: Symbol(), AUTHORIZED: Symbol(), LEGACY: Symbol(), }; -export function hasPrivilegesWithServer(server) { +export function checkPrivilegesWithRequestFactory(server) { const callWithRequest = getClient(server).callWithRequest; const config = server.config(); @@ -25,9 +25,9 @@ export function hasPrivilegesWithServer(server) { const loginAction = getLoginAction(); const versionAction = getVersionAction(kibanaVersion); - return function hasPrivilegesWithRequest(request) { + return function checkPrivilegesWithRequest(request) { - const hasApplicationPrivileges = async (privileges) => { + const checkApplicationPrivileges = async (privileges) => { const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { body: { applications: [{ @@ -67,38 +67,38 @@ export function hasPrivilegesWithServer(server) { return Object.values(privilegeCheck.index[kibanaIndex]).includes(true); }; - return async function hasPrivileges(privileges) { + return async function checkPrivileges(privileges) { const allPrivileges = [versionAction, loginAction, ...privileges]; - const privilegesCheck = await hasApplicationPrivileges(allPrivileges); + const applicationPrivilegesCheck = await checkApplicationPrivileges(allPrivileges); - const username = privilegesCheck.username; + const username = applicationPrivilegesCheck.username; // We don't want to expose the version privilege to consumers, as it's an implementation detail only to detect version mismatch - const missing = Object.keys(privilegesCheck.privileges) - .filter(p => !privilegesCheck.privileges[p]) + const missing = Object.keys(applicationPrivilegesCheck.privileges) + .filter(p => !applicationPrivilegesCheck.privileges[p]) .filter(p => p !== versionAction); - if (privilegesCheck.hasAllRequested) { + if (applicationPrivilegesCheck.hasAllRequested) { return { - result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, username, missing, }; } - if (!privilegesCheck.privileges[loginAction] && await hasPrivilegesOnKibanaIndex()) { - const msg = `Relying on implicit privileges determined from the index privileges is deprecated and will be removed in Kibana 7.0`; + if (!applicationPrivilegesCheck.privileges[loginAction] && await hasPrivilegesOnKibanaIndex()) { + const msg = 'Relying on index privileges is deprecated and will be removed in Kibana 7.0'; server.log(['warning', 'deprecated', 'security'], msg); return { - result: HAS_PRIVILEGES_RESULT.LEGACY, + result: CHECK_PRIVILEGES_RESULT.LEGACY, username, missing, }; } return { - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing, }; diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js similarity index 84% rename from x-pack/plugins/security/server/lib/authorization/has_privileges.test.js rename to x-pack/plugins/security/server/lib/authorization/check_privileges.test.js index 5942e0058691b9..13299709577f04 100644 --- a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { hasPrivilegesWithServer, HAS_PRIVILEGES_RESULT } from './has_privileges'; +import { checkPrivilegesWithRequestFactory, CHECK_PRIVILEGES_RESULT } from './check_privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; import { DEFAULT_RESOURCE } from '../../../common/constants'; import { getLoginAction, getVersionAction } from '../privileges'; @@ -76,12 +76,12 @@ const createMockCallWithRequest = (responses) => { return mockCallWithRequest; }; +const deprecationMessage = 'Relying on index privileges is deprecated and will be removed in Kibana 7.0'; const expectNoDeprecationLogged = (mockServer) => { - expect(mockServer.log).not.toHaveBeenCalled(); + expect(mockServer.log).not.toHaveBeenCalledWith(['warning', 'deprecated', 'security'], deprecationMessage); }; - const expectDeprecationLogged = (mockServer) => { - expect(mockServer.log).toHaveBeenCalledWith(['warning', 'deprecated', 'security'], expect.stringContaining('deprecated')); + expect(mockServer.log).toHaveBeenCalledWith(['warning', 'deprecated', 'security'], deprecationMessage); }; test(`returns authorized if they have all application privileges`, async () => { @@ -101,11 +101,11 @@ test(`returns authorized if they have all application privileges`, async () => { }) ]); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockServer); const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); + const checkPrivileges = checkPrivilegesWithRequest(request); const privileges = [privilege]; - const result = await hasPrivileges(privileges); + const result = await checkPrivileges(privileges); expectNoDeprecationLogged(mockServer); expect(mockCallWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { @@ -120,7 +120,7 @@ test(`returns authorized if they have all application privileges`, async () => { } }); expect(result).toEqual({ - result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, username, missing: [], }); @@ -145,11 +145,11 @@ test(`returns unauthorized they have only one application privilege`, async () = }) ]); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockServer); const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); + const checkPrivileges = checkPrivilegesWithRequest(request); const privileges = [privilege1, privilege2]; - const result = await hasPrivileges(privileges); + const result = await checkPrivileges(privileges); expectNoDeprecationLogged(mockServer); expect(mockCallWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { @@ -164,7 +164,7 @@ test(`returns unauthorized they have only one application privilege`, async () = } }); expect(result).toEqual({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [privilege2], }); @@ -184,10 +184,10 @@ test(`throws error if missing version privilege and has login privilege`, async }) ]); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); - const hasPrivileges = hasPrivilegesWithRequest({}); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockServer); + const checkPrivileges = checkPrivilegesWithRequest({}); - await expect(hasPrivileges([privilege])).rejects.toThrowErrorMatchingSnapshot(); + await expect(checkPrivileges([privilege])).rejects.toThrowErrorMatchingSnapshot(); expectNoDeprecationLogged(mockServer); }); @@ -216,11 +216,11 @@ describe('legacy fallback with no application privileges', () => { }) ]); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockServer); const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); + const checkPrivileges = checkPrivilegesWithRequest(request); const privileges = [privilege]; - const result = await hasPrivileges(privileges); + const result = await checkPrivileges(privileges); expectNoDeprecationLogged(mockServer); expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { @@ -243,7 +243,7 @@ describe('legacy fallback with no application privileges', () => { } }); expect(result).toEqual({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [getLoginAction(), ...privileges], }); @@ -275,11 +275,11 @@ describe('legacy fallback with no application privileges', () => { }) ]); - const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockServer); const request = Symbol(); - const hasPrivileges = hasPrivilegesWithRequest(request); + const checkPrivileges = checkPrivilegesWithRequest(request); const privileges = [privilege]; - const result = await hasPrivileges(privileges); + const result = await checkPrivileges(privileges); expectDeprecationLogged(mockServer); expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { @@ -302,7 +302,7 @@ describe('legacy fallback with no application privileges', () => { } }); expect(result).toEqual({ - result: HAS_PRIVILEGES_RESULT.LEGACY, + result: CHECK_PRIVILEGES_RESULT.LEGACY, username, missing: [getLoginAction(), ...privileges], }); diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js index fce39c03d6e06d..969990593f0c47 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js @@ -5,7 +5,7 @@ */ import { get, uniq } from 'lodash'; -import { HAS_PRIVILEGES_RESULT } from '../authorization/has_privileges'; +import { CHECK_PRIVILEGES_RESULT } from '../authorization/check_privileges'; const getPrivilege = (type, action) => { return `action:saved_objects/${type}/${action}`; @@ -17,7 +17,7 @@ export class SecureSavedObjectsClient { errors, internalRepository, callWithRequestRepository, - hasPrivileges, + checkPrivileges, auditLogger, savedObjectTypes, } = options; @@ -25,7 +25,7 @@ export class SecureSavedObjectsClient { this.errors = errors; this._internalRepository = internalRepository; this._callWithRequestRepository = callWithRequestRepository; - this._hasPrivileges = hasPrivileges; + this._checkPrivileges = checkPrivileges; this._auditLogger = auditLogger; this._savedObjectTypes = savedObjectTypes; } @@ -106,15 +106,15 @@ export class SecureSavedObjectsClient { async _execute(typeOrTypes, action, args, fn) { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; const privileges = types.map(type => getPrivilege(type, action)); - const { result, username, missing } = await this._hasSavedObjectPrivileges(privileges); + const { result, username, missing } = await this._checkSavedObjectPrivileges(privileges); switch (result) { - case HAS_PRIVILEGES_RESULT.AUTHORIZED: + case CHECK_PRIVILEGES_RESULT.AUTHORIZED: this._auditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); return await fn(this._internalRepository); - case HAS_PRIVILEGES_RESULT.LEGACY: + case CHECK_PRIVILEGES_RESULT.LEGACY: return await fn(this._callWithRequestRepository); - case HAS_PRIVILEGES_RESULT.UNAUTHORIZED: + case CHECK_PRIVILEGES_RESULT.UNAUTHORIZED: this._auditLogger.savedObjectsAuthorizationFailure(username, action, types, missing, args); const msg = `Unable to ${action} ${types.sort().join(',')}, missing ${missing.sort().join(',')}`; throw this.errors.decorateForbiddenError(new Error(msg)); @@ -129,9 +129,9 @@ export class SecureSavedObjectsClient { // we have to filter for only their authorized types const types = this._savedObjectTypes; const typesToPrivilegesMap = new Map(types.map(type => [type, getPrivilege(type, action)])); - const { result, username, missing } = await this._hasSavedObjectPrivileges(Array.from(typesToPrivilegesMap.values())); + const { result, username, missing } = await this._checkSavedObjectPrivileges(Array.from(typesToPrivilegesMap.values())); - if (result === HAS_PRIVILEGES_RESULT.LEGACY) { + if (result === CHECK_PRIVILEGES_RESULT.LEGACY) { return await this._callWithRequestRepository.find(options); } @@ -158,9 +158,9 @@ export class SecureSavedObjectsClient { }); } - async _hasSavedObjectPrivileges(privileges) { + async _checkSavedObjectPrivileges(privileges) { try { - return await this._hasPrivileges(privileges); + return await this._checkPrivileges(privileges); } catch(error) { const { reason } = get(error, 'body.error', {}); throw this.errors.decorateGeneralError(error, reason); diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js index 4d77c48c56b950..5e1b1f71165246 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js @@ -5,7 +5,7 @@ */ import { SecureSavedObjectsClient } from './secure_saved_objects_client'; -import { HAS_PRIVILEGES_RESULT } from '../authorization/has_privileges'; +import { CHECK_PRIVILEGES_RESULT } from '../authorization/check_privileges'; const createMockErrors = () => { const forbiddenError = new Error('Mock ForbiddenError'); @@ -40,19 +40,19 @@ describe('#create', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { + const mockCheckPrivileges = jest.fn().mockImplementation(async () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/create`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/create`]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -62,8 +62,8 @@ describe('#create', () => { const type = 'foo'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ `action:saved_objects/${type}/create` @@ -72,7 +72,7 @@ describe('#create', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const attributes = Symbol(); @@ -80,7 +80,7 @@ describe('#create', () => { await expect(client.create(type, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/create`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/create`]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, @@ -103,14 +103,14 @@ describe('#create', () => { const mockRepository = { create: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ internalRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const attributes = Symbol(); @@ -135,8 +135,8 @@ describe('#create', () => { const mockRepository = { create: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.LEGACY, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.LEGACY, username, missing: [ `action:saved_objects/${type}/create` @@ -145,7 +145,7 @@ describe('#create', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const attributes = Symbol(); @@ -164,19 +164,19 @@ describe('#bulkCreate', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { + const mockCheckPrivileges = jest.fn().mockImplementation(async () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); await expect(client.bulkCreate([{ type }])).rejects.toThrowError(mockErrors.generalError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/bulk_create`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/bulk_create`]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -187,8 +187,8 @@ describe('#bulkCreate', () => { const type2 = 'bar'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ `action:saved_objects/${type1}/bulk_create` @@ -197,7 +197,7 @@ describe('#bulkCreate', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const objects = [ @@ -209,7 +209,7 @@ describe('#bulkCreate', () => { await expect(client.bulkCreate(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockHasPrivileges).toHaveBeenCalledWith([ + expect(mockCheckPrivileges).toHaveBeenCalledWith([ `action:saved_objects/${type1}/bulk_create`, `action:saved_objects/${type2}/bulk_create` ]); @@ -234,14 +234,14 @@ describe('#bulkCreate', () => { const mockRepository = { bulkCreate: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ internalRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const objects = [ @@ -269,8 +269,8 @@ describe('#bulkCreate', () => { const mockRepository = { bulkCreate: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.LEGACY, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.LEGACY, username, missing: [ `action:saved_objects/${type1}/bulk_create`, @@ -280,7 +280,7 @@ describe('#bulkCreate', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const objects = [ @@ -302,19 +302,19 @@ describe('#delete', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { + const mockCheckPrivileges = jest.fn().mockImplementation(async () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/delete`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/delete`]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -324,8 +324,8 @@ describe('#delete', () => { const type = 'foo'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ `action:saved_objects/${type}/delete` @@ -334,14 +334,14 @@ describe('#delete', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const id = Symbol(); await expect(client.delete(type, id)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/delete`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/delete`]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, @@ -363,14 +363,14 @@ describe('#delete', () => { const mockRepository = { delete: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ internalRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const id = Symbol(); @@ -393,8 +393,8 @@ describe('#delete', () => { const mockRepository = { delete: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.LEGACY, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.LEGACY, username, missing: [ `action:saved_objects/${type}/delete` @@ -403,7 +403,7 @@ describe('#delete', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const id = Symbol(); @@ -422,19 +422,19 @@ describe('#find', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { + const mockCheckPrivileges = jest.fn().mockImplementation(async () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -445,8 +445,8 @@ describe('#find', () => { const username = Symbol(); const mockRepository = {}; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ `action:saved_objects/${type}/find` @@ -456,14 +456,14 @@ describe('#find', () => { const client = new SecureSavedObjectsClient({ errors: mockErrors, internalRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const options = { type }; await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, @@ -482,8 +482,8 @@ describe('#find', () => { const type2 = 'bar'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ `action:saved_objects/${type1}/find` @@ -492,14 +492,14 @@ describe('#find', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const options = { type: [ type1, type2 ] }; await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, @@ -519,8 +519,8 @@ describe('#find', () => { const username = Symbol(); const mockRepository = {}; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ `action:saved_objects/${type1}/find`, @@ -531,14 +531,14 @@ describe('#find', () => { const client = new SecureSavedObjectsClient({ errors: mockErrors, repository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const options = { type: [ type1, type2 ] }; await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, @@ -559,14 +559,14 @@ describe('#find', () => { const mockRepository = { find: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ internalRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const options = { type }; @@ -588,8 +588,8 @@ describe('#find', () => { const mockRepository = { find: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.LEGACY, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.LEGACY, username, missing: [ `action:saved_objects/${type}/find` @@ -598,7 +598,7 @@ describe('#find', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const options = { type }; @@ -618,21 +618,21 @@ describe('#find', () => { const type2 = 'bar'; const mockRepository = {}; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { + const mockCheckPrivileges = jest.fn().mockImplementation(async () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, repository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, savedObjectTypes: [type1, type2] }); await expect(client.find()).rejects.toThrowError(mockErrors.generalError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -643,8 +643,8 @@ describe('#find', () => { const username = Symbol(); const mockRepository = {}; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ `action:saved_objects/${type}/find` @@ -654,7 +654,7 @@ describe('#find', () => { const client = new SecureSavedObjectsClient({ errors: mockErrors, repository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, savedObjectTypes: [type] }); @@ -662,7 +662,7 @@ describe('#find', () => { await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, @@ -684,8 +684,8 @@ describe('#find', () => { find: jest.fn().mockReturnValue(returnValue) }; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.LEGACY, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.LEGACY, username, missing: [ `action:saved_objects/${type}/find` @@ -695,7 +695,7 @@ describe('#find', () => { const client = new SecureSavedObjectsClient({ errors: mockErrors, callWithRequestRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, savedObjectTypes: [type] }); @@ -704,7 +704,7 @@ describe('#find', () => { const result = await client.find(options); expect(result).toBe(returnValue); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); expect(mockRepository.find).toHaveBeenCalledWith(options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -717,8 +717,8 @@ describe('#find', () => { find: jest.fn(), }; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, missing: [ `action:saved_objects/${type1}/find` ] @@ -727,14 +727,14 @@ describe('#find', () => { const client = new SecureSavedObjectsClient({ errors: mockErrors, internalRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, savedObjectTypes: [type1, type2] }); await client.find(); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); expect(mockRepository.find).toHaveBeenCalledWith(expect.objectContaining({ type: [type2] })); @@ -747,15 +747,15 @@ describe('#find', () => { const mockRepository = { find: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, username, missing: [], })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ internalRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, savedObjectTypes: [type] }); @@ -777,19 +777,19 @@ describe('#bulkGet', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { + const mockCheckPrivileges = jest.fn().mockImplementation(async () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); - expect(mockHasPrivileges).toHaveBeenCalledWith(['action:saved_objects/foo/bulk_get']); + expect(mockCheckPrivileges).toHaveBeenCalledWith(['action:saved_objects/foo/bulk_get']); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -800,8 +800,8 @@ describe('#bulkGet', () => { const type2 = 'bar'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ `action:saved_objects/${type1}/bulk_get` @@ -810,7 +810,7 @@ describe('#bulkGet', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const objects = [ @@ -821,7 +821,7 @@ describe('#bulkGet', () => { await expect(client.bulkGet(objects)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/bulk_get`, `action:saved_objects/${type2}/bulk_get`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/bulk_get`, `action:saved_objects/${type2}/bulk_get`]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, @@ -843,14 +843,14 @@ describe('#bulkGet', () => { const mockRepository = { bulkGet: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ internalRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const objects = [ @@ -876,8 +876,8 @@ describe('#bulkGet', () => { const mockRepository = { bulkGet: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.LEGACY, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.LEGACY, username, missing: [ `action:saved_objects/${type1}/bulk_get`, @@ -887,7 +887,7 @@ describe('#bulkGet', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const objects = [ @@ -908,19 +908,19 @@ describe('#get', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { + const mockCheckPrivileges = jest.fn().mockImplementation(async () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/get`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/get`]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -930,8 +930,8 @@ describe('#get', () => { const type = 'foo'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ `action:saved_objects/${type}/get` @@ -940,14 +940,14 @@ describe('#get', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const id = Symbol(); await expect(client.get(type, id)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/get`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/get`]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, @@ -969,14 +969,14 @@ describe('#get', () => { const mockRepository = { get: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ internalRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const id = Symbol(); @@ -999,8 +999,8 @@ describe('#get', () => { const mockRepository = { get: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.LEGACY, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.LEGACY, username, missing: [ `action:saved_objects/${type}/get` @@ -1009,7 +1009,7 @@ describe('#get', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const id = Symbol(); @@ -1027,19 +1027,19 @@ describe('#update', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => { + const mockCheckPrivileges = jest.fn().mockImplementation(async () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/update`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/update`]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -1049,8 +1049,8 @@ describe('#update', () => { const type = 'foo'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.UNAUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ 'action:saved_objects/foo/update' @@ -1059,7 +1059,7 @@ describe('#update', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const id = Symbol(); @@ -1068,7 +1068,7 @@ describe('#update', () => { await expect(client.update(type, id, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockHasPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/update`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/update`]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, @@ -1092,14 +1092,14 @@ describe('#update', () => { const mockRepository = { update: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.AUTHORIZED, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, username, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ internalRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const id = Symbol(); @@ -1126,8 +1126,8 @@ describe('#update', () => { const mockRepository = { update: jest.fn().mockReturnValue(returnValue) }; - const mockHasPrivileges = jest.fn().mockImplementation(async () => ({ - result: HAS_PRIVILEGES_RESULT.LEGACY, + const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + result: CHECK_PRIVILEGES_RESULT.LEGACY, username, missing: [ 'action:saved_objects/foo/update' @@ -1136,7 +1136,7 @@ describe('#update', () => { const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, - hasPrivileges: mockHasPrivileges, + checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, }); const id = Symbol(); From c1689f7f315a6726c9295bbb66b29a3cb2dd31fa Mon Sep 17 00:00:00 2001 From: kobelb Date: Mon, 2 Jul 2018 14:22:44 -0400 Subject: [PATCH 065/183] Revising check_license tests --- .../server/lib/__tests__/check_license.js | 68 ++----------------- 1 file changed, 5 insertions(+), 63 deletions(-) diff --git a/x-pack/plugins/security/server/lib/__tests__/check_license.js b/x-pack/plugins/security/server/lib/__tests__/check_license.js index 53e3eb09eefdfa..bd5420af37d00e 100644 --- a/x-pack/plugins/security/server/lib/__tests__/check_license.js +++ b/x-pack/plugins/security/server/lib/__tests__/check_license.js @@ -18,7 +18,6 @@ describe('check_license', function () { feature: sinon.stub(), license: sinon.stub({ isOneOf() {}, - isActive() {} }) }; @@ -42,7 +41,6 @@ describe('check_license', function () { it('should not show login page or other security elements if license is basic.', () => { mockXPackInfo.license.isOneOf.withArgs(['basic']).returns(true); - mockXPackInfo.license.isActive.returns(true); mockXPackInfo.feature.withArgs('security').returns({ isEnabled: () => { return true; } }); @@ -61,7 +59,6 @@ describe('check_license', function () { it('should not show login page or other security elements if security is disabled in Elasticsearch.', () => { mockXPackInfo.license.isOneOf.withArgs(['basic']).returns(false); - mockXPackInfo.license.isActive.returns(true); mockXPackInfo.feature.withArgs('security').returns({ isEnabled: () => { return false; } }); @@ -78,30 +75,14 @@ describe('check_license', function () { }); }); - it('should allow to login but forbid document level security if license is not platinum, trial or basic.', () => { - const isBasicOrTrialOrPlatinumMatcher = sinon.match( - (licenses) => licenses.includes('basic') - || licenses.includes('trial') - || licenses.includes('platinum') - ); + it('should allow to login and allow RBAC but forbid document level security if license is not platinum or trial.', () => { mockXPackInfo.license.isOneOf - .returns(true) - .withArgs(isBasicOrTrialOrPlatinumMatcher).returns(false); + .returns(false) + .withArgs(['platinum', 'trial']).returns(false); mockXPackInfo.feature.withArgs('security').returns({ isEnabled: () => { return true; } }); - mockXPackInfo.license.isActive.returns(true); - expect(checkLicense(mockXPackInfo)).to.be.eql({ - showLogin: true, - allowLogin: true, - showLinks: true, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - allowRbac: true, - }); - - mockXPackInfo.license.isActive.returns(false); expect(checkLicense(mockXPackInfo)).to.be.eql({ showLogin: true, allowLogin: true, @@ -112,25 +93,14 @@ describe('check_license', function () { }); }); - it('should allow to login and document level security if license is platinum.', () => { + it('should allow to login, allow RBAC and document level security if license is platinum or trial.', () => { mockXPackInfo.license.isOneOf .returns(false) - .withArgs(sinon.match((licenses) => licenses.includes('platinum'))).returns(true); + .withArgs(['platinum', 'trial']).returns(true); mockXPackInfo.feature.withArgs('security').returns({ isEnabled: () => { return true; } }); - mockXPackInfo.license.isActive.returns(true); - expect(checkLicense(mockXPackInfo)).to.be.eql({ - showLogin: true, - allowLogin: true, - showLinks: true, - allowRoleDocumentLevelSecurity: true, - allowRoleFieldLevelSecurity: true, - allowRbac: true, - }); - - mockXPackInfo.license.isActive.returns(false); expect(checkLicense(mockXPackInfo)).to.be.eql({ showLogin: true, allowLogin: true, @@ -141,32 +111,4 @@ describe('check_license', function () { }); }); - it('should allow to login and document level security if license is trial.', () => { - mockXPackInfo.license.isOneOf - .returns(false) - .withArgs(sinon.match((licenses) => licenses.includes('trial'))).returns(true); - mockXPackInfo.feature.withArgs('security').returns({ - isEnabled: () => { return true; } - }); - - mockXPackInfo.license.isActive.returns(true); - expect(checkLicense(mockXPackInfo)).to.be.eql({ - showLogin: true, - allowLogin: true, - showLinks: true, - allowRoleDocumentLevelSecurity: true, - allowRoleFieldLevelSecurity: true, - allowRbac: true, - }); - - mockXPackInfo.license.isActive.returns(false); - expect(checkLicense(mockXPackInfo)).to.be.eql({ - showLogin: true, - allowLogin: true, - showLinks: true, - allowRoleDocumentLevelSecurity: true, - allowRoleFieldLevelSecurity: true, - allowRbac: true, - }); - }); }); From c7ae3e89e2dcb358b1267cc04764d720a64c4405 Mon Sep 17 00:00:00 2001 From: kobelb Date: Mon, 2 Jul 2018 14:44:46 -0400 Subject: [PATCH 066/183] Adding 2 more privileges tests --- .../privilege_action_registry.test.js | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js index 8e2eac429724b1..59b354fc247712 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js @@ -170,7 +170,7 @@ registerPrivilegesWithClusterTest(`passes saved object types, application and ki }, }); -registerPrivilegesWithClusterTest(`updates privileges when simple top-level privileges don't match`, { +registerPrivilegesWithClusterTest(`updates privileges when simple top-level privileges values don't match`, { expectedPrivileges: { expected: true }, @@ -182,7 +182,19 @@ registerPrivilegesWithClusterTest(`updates privileges when simple top-level priv } }); -registerPrivilegesWithClusterTest(`updates privileges when nested privileges don't match`, { +registerPrivilegesWithClusterTest(`updates privileges when we have two different simple top-level privileges`, { + expectedPrivileges: { + notExpected: true + }, + existingPrivileges: { + expected: true + }, + assert: ({ expectUpdatedPrivileges }) => { + expectUpdatedPrivileges(); + } +}); + +registerPrivilegesWithClusterTest(`updates privileges when nested privileges values don't match`, { expectedPrivileges: { kibana: { expected: true @@ -198,6 +210,22 @@ registerPrivilegesWithClusterTest(`updates privileges when nested privileges don } }); +registerPrivilegesWithClusterTest(`updates privileges when we have two different nested privileges`, { + expectedPrivileges: { + kibana: { + notExpected: true + } + }, + existingPrivileges: { + kibana: { + expected: false + } + }, + assert: ({ expectUpdatedPrivileges }) => { + expectUpdatedPrivileges(); + } +}); + registerPrivilegesWithClusterTest(`updates privileges when nested privileges arrays don't match`, { expectedPrivileges: { kibana: { From 03f7931a6ae03dbdce01f08e760c769bdf806f74 Mon Sep 17 00:00:00 2001 From: kobelb Date: Mon, 2 Jul 2018 14:46:32 -0400 Subject: [PATCH 067/183] Moving the other _find method to be near his friend --- .../secure_saved_objects_client.js | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js index 969990593f0c47..419a379f7d0239 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js @@ -66,15 +66,6 @@ export class SecureSavedObjectsClient { return await this._findAcrossAllTypes(options); } - async _findWithTypes(options) { - return await this._execute( - options.type, - 'find', - { options }, - repository => repository.find(options) - ); - } - async bulkGet(objects = []) { const types = uniq(objects.map(o => o.type)); return await this._execute( @@ -103,6 +94,15 @@ export class SecureSavedObjectsClient { ); } + async _checkSavedObjectPrivileges(privileges) { + try { + return await this._checkPrivileges(privileges); + } catch(error) { + const { reason } = get(error, 'body.error', {}); + throw this.errors.decorateGeneralError(error, reason); + } + } + async _execute(typeOrTypes, action, args, fn) { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; const privileges = types.map(type => getPrivilege(type, action)); @@ -158,12 +158,12 @@ export class SecureSavedObjectsClient { }); } - async _checkSavedObjectPrivileges(privileges) { - try { - return await this._checkPrivileges(privileges); - } catch(error) { - const { reason } = get(error, 'body.error', {}); - throw this.errors.decorateGeneralError(error, reason); - } + async _findWithTypes(options) { + return await this._execute( + options.type, + 'find', + { options }, + repository => repository.find(options) + ); } } From 98acdc00b22731cd7f7d5bdd1273d3e4a0f344d1 Mon Sep 17 00:00:00 2001 From: kobelb Date: Mon, 2 Jul 2018 14:49:08 -0400 Subject: [PATCH 068/183] Spelling "returning" correctly, whoops --- x-pack/plugins/security/server/routes/api/v1/privileges.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security/server/routes/api/v1/privileges.js b/x-pack/plugins/security/server/routes/api/v1/privileges.js index 273994b25e562b..e3c830bf176885 100644 --- a/x-pack/plugins/security/server/routes/api/v1/privileges.js +++ b/x-pack/plugins/security/server/routes/api/v1/privileges.js @@ -16,7 +16,7 @@ export function initPrivilegesApi(server) { method: 'GET', path: '/api/security/v1/privileges', handler(request, reply) { - // we're returing our representation of the privileges, as opposed to the ones that are stored + // we're returning our representation of the privileges, as opposed to the ones that are stored // in Elasticsearch because our current thinking is that we'll associate additional structure/metadata // with our view of them to allow users to more efficiently edit privileges for roles, and serialize it // into a different structure for enforcement within Elasticsearch From 3a95af31f6a030344e04d76e128c5e320dd18f3b Mon Sep 17 00:00:00 2001 From: kobelb Date: Mon, 2 Jul 2018 15:14:20 -0400 Subject: [PATCH 069/183] Adding Privileges tests --- .../test/rbac_api_integration/apis/index.js | 1 + .../apis/privileges/index.js | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 x-pack/test/rbac_api_integration/apis/privileges/index.js diff --git a/x-pack/test/rbac_api_integration/apis/index.js b/x-pack/test/rbac_api_integration/apis/index.js index eff74e5f38dbe2..0d89728aa04ff6 100644 --- a/x-pack/test/rbac_api_integration/apis/index.js +++ b/x-pack/test/rbac_api_integration/apis/index.js @@ -6,6 +6,7 @@ export default function ({ loadTestFile }) { describe('apis RBAC', () => { + loadTestFile(require.resolve('./privileges')); loadTestFile(require.resolve('./saved_objects')); }); } diff --git a/x-pack/test/rbac_api_integration/apis/privileges/index.js b/x-pack/test/rbac_api_integration/apis/privileges/index.js new file mode 100644 index 00000000000000..bc5bc62f608384 --- /dev/null +++ b/x-pack/test/rbac_api_integration/apis/privileges/index.js @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from 'expect.js'; + +export default function ({ getService }) { + describe('privileges', () => { + it(`get should return privileges`, async () => { + const supertest = getService('supertest'); + + await supertest + .get(`/api/security/v1/privileges`) + .expect(200) + .then(resp => { + expect(resp.body).to.eql([ + { + application: 'kibana', + name: 'all', + actions: ['version:7.0.0-alpha1', 'action:*'], + metadata: {}, + }, + { + application: 'kibana', + name: 'read', + actions: [ + 'version:7.0.0-alpha1', + 'action:login', + 'action:saved_objects/config/get', + 'action:saved_objects/config/bulk_get', + 'action:saved_objects/config/find', + 'action:saved_objects/timelion-sheet/get', + 'action:saved_objects/timelion-sheet/bulk_get', + 'action:saved_objects/timelion-sheet/find', + 'action:saved_objects/graph-workspace/get', + 'action:saved_objects/graph-workspace/bulk_get', + 'action:saved_objects/graph-workspace/find', + 'action:saved_objects/index-pattern/get', + 'action:saved_objects/index-pattern/bulk_get', + 'action:saved_objects/index-pattern/find', + 'action:saved_objects/visualization/get', + 'action:saved_objects/visualization/bulk_get', + 'action:saved_objects/visualization/find', + 'action:saved_objects/search/get', + 'action:saved_objects/search/bulk_get', + 'action:saved_objects/search/find', + 'action:saved_objects/dashboard/get', + 'action:saved_objects/dashboard/bulk_get', + 'action:saved_objects/dashboard/find', + 'action:saved_objects/url/get', + 'action:saved_objects/url/bulk_get', + 'action:saved_objects/url/find', + 'action:saved_objects/server/get', + 'action:saved_objects/server/bulk_get', + 'action:saved_objects/server/find', + ], + metadata: {}, + }, + ]); + }); + }); + }); +} From 3299a0a34bc69f5b32b06f5d723f2c7b288d3a94 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 5 Jul 2018 11:21:38 -0400 Subject: [PATCH 070/183] tests for Elasticsearch's privileges APIs --- x-pack/test/api_integration/config.js | 2 + .../services/es_supertest_without_auth.js | 23 +++ x-pack/test/api_integration/services/index.js | 1 + .../apis/es/has_privileges.js | 140 ++++++++++++++++++ .../rbac_api_integration/apis/es/index.js | 12 ++ .../apis/es/post_privileges.js | 98 ++++++++++++ .../test/rbac_api_integration/apis/index.js | 1 + x-pack/test/rbac_api_integration/config.js | 1 + 8 files changed, 278 insertions(+) create mode 100644 x-pack/test/api_integration/services/es_supertest_without_auth.js create mode 100644 x-pack/test/rbac_api_integration/apis/es/has_privileges.js create mode 100644 x-pack/test/rbac_api_integration/apis/es/index.js create mode 100644 x-pack/test/rbac_api_integration/apis/es/post_privileges.js diff --git a/x-pack/test/api_integration/config.js b/x-pack/test/api_integration/config.js index e68fafa3597682..ac1d8225e294e8 100644 --- a/x-pack/test/api_integration/config.js +++ b/x-pack/test/api_integration/config.js @@ -5,6 +5,7 @@ */ import { + EsSupertestWithoutAuthProvider, SupertestWithoutAuthProvider, UsageAPIProvider, } from './services'; @@ -22,6 +23,7 @@ export default async function ({ readConfigFile }) { supertest: kibanaAPITestsConfig.get('services.supertest'), esSupertest: kibanaAPITestsConfig.get('services.esSupertest'), supertestWithoutAuth: SupertestWithoutAuthProvider, + esSupertestWithoutAuth: EsSupertestWithoutAuthProvider, es: kibanaCommonConfig.get('services.es'), esArchiver: kibanaCommonConfig.get('services.esArchiver'), usageAPI: UsageAPIProvider, diff --git a/x-pack/test/api_integration/services/es_supertest_without_auth.js b/x-pack/test/api_integration/services/es_supertest_without_auth.js new file mode 100644 index 00000000000000..7f0f051bbdfbb7 --- /dev/null +++ b/x-pack/test/api_integration/services/es_supertest_without_auth.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { format as formatUrl } from 'url'; + +import supertestAsPromised from 'supertest-as-promised'; + +/** + * Supertest provider that doesn't include user credentials into base URL that is passed + * to the supertest. + */ +export function EsSupertestWithoutAuthProvider({ getService }) { + const config = getService('config'); + const elasticsearchServerConfig = config.get('servers.elasticsearch'); + + return supertestAsPromised(formatUrl({ + ...elasticsearchServerConfig, + auth: false + })); +} diff --git a/x-pack/test/api_integration/services/index.js b/x-pack/test/api_integration/services/index.js index 52ec6ba95586af..9caab94932a1c1 100644 --- a/x-pack/test/api_integration/services/index.js +++ b/x-pack/test/api_integration/services/index.js @@ -4,5 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export { EsSupertestWithoutAuthProvider } from './es_supertest_without_auth'; export { SupertestWithoutAuthProvider } from './supertest_without_auth'; export { UsageAPIProvider } from './usage_api'; diff --git a/x-pack/test/rbac_api_integration/apis/es/has_privileges.js b/x-pack/test/rbac_api_integration/apis/es/has_privileges.js new file mode 100644 index 00000000000000..1e30eb774cc029 --- /dev/null +++ b/x-pack/test/rbac_api_integration/apis/es/has_privileges.js @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 'expect.js'; + +const application = 'has_privileges_test'; + +export default function ({ getService }) { + + describe('has_privileges', () => { + before(async () => { + const es = getService('es'); + + await es.shield.postPrivileges({ + body: { + [application]: { + read: { + application, + name: 'read', + actions: ['action:readAction1', 'action:readAction2'], + metadata: {}, + } + } + } + }); + + await es.shield.putRole({ + name: 'hp_read_user', + body: { + cluster: [], + index: [], + applications: [{ + application, + privileges: ['read'], + resources: ['*'] + }] + } + }); + + await es.shield.putUser({ + username: 'testuser', + body: { + password: 'testpassword', + roles: ['hp_read_user'], + full_name: 'a kibana user', + email: 'a_kibana_rbac_user@elastic.co', + } + }); + }); + + function createHasPrivilegesRequest(privileges) { + const supertest = getService('esSupertestWithoutAuth'); + return supertest + .post(`/_xpack/security/user/_has_privileges`) + .auth('testuser', 'testpassword') + .send({ + applications: [{ + application, + privileges, + resources: ['*'] + }] + }) + .expect(200); + } + + it('should return true when user has the requested privilege', async () => { + await createHasPrivilegesRequest(['read']) + .then(response => { + expect(response.body).to.eql({ + username: 'testuser', + has_all_requested: true, + cluster: {}, + index: {}, + application: { + has_privileges_test: { + ['*']: { + read: true + } + }, + } + }); + }); + }); + + it('should return true when user has a newly created privilege', async () => { + // verify user does not have privilege yet + await createHasPrivilegesRequest(['action:a_new_privilege']) + .then(response => { + expect(response.body).to.eql({ + username: 'testuser', + has_all_requested: false, + cluster: {}, + index: {}, + application: { + has_privileges_test: { + ['*']: { + 'action:a_new_privilege': false + } + }, + } + }); + }); + + // Create privilege + const es = getService('es'); + await es.shield.postPrivileges({ + body: { + [application]: { + read: { + application, + name: 'read', + actions: ['action:readAction1', 'action:readAction2', 'action:a_new_privilege'], + metadata: {}, + } + } + } + }); + + // verify user has new privilege + await createHasPrivilegesRequest(['action:a_new_privilege']) + .then(response => { + expect(response.body).to.eql({ + username: 'testuser', + has_all_requested: true, + cluster: {}, + index: {}, + application: { + has_privileges_test: { + ['*']: { + 'action:a_new_privilege': true + } + }, + } + }); + }); + }); + }); +} diff --git a/x-pack/test/rbac_api_integration/apis/es/index.js b/x-pack/test/rbac_api_integration/apis/es/index.js new file mode 100644 index 00000000000000..6317d6b93878fc --- /dev/null +++ b/x-pack/test/rbac_api_integration/apis/es/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 default function ({ loadTestFile }) { + describe('rbac es', () => { + loadTestFile(require.resolve('./has_privileges')); + loadTestFile(require.resolve('./post_privileges')); + }); +} diff --git a/x-pack/test/rbac_api_integration/apis/es/post_privileges.js b/x-pack/test/rbac_api_integration/apis/es/post_privileges.js new file mode 100644 index 00000000000000..bf2266575d3b1f --- /dev/null +++ b/x-pack/test/rbac_api_integration/apis/es/post_privileges.js @@ -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 expect from 'expect.js'; + +export default function ({ getService }) { + + describe('post_privileges', () => { + it('should allow privileges to be updated', async () => { + const es = getService('es'); + const application = 'foo'; + const response = await es.shield.postPrivileges({ + body: { + [application]: { + all: { + application, + name: 'all', + actions: ['action:*'], + metadata: {}, + }, + read: { + application, + name: 'read', + actions: ['action:readAction1', 'action:readAction2'], + metadata: {}, + } + } + } + }); + + expect(response).to.eql({ + foo: { + all: { created: true }, + read: { created: true } + } + }); + + + // Update privileges: + // 1. Not specifying the "all" privilege that we created above + // 2. Specifying a different collection of "read" actions + // 3. Adding a new "other" privilege + const updateResponse = await es.shield.postPrivileges({ + body: { + [application]: { + read: { + application, + name: 'read', + actions: ['action:readAction1', 'action:readAction4'], + metadata: {} + }, + other: { + application, + name: 'other', + actions: ['action:otherAction1'], + metadata: {}, + } + } + } + }); + + expect(updateResponse).to.eql({ + foo: { + other: { created: true }, + read: { created: false } + } + }); + + const retrievedPrivilege = await es.shield.getPrivilege({ privilege: application }); + expect(retrievedPrivilege).to.eql({ + foo: { + // "all" is maintained even though the subsequent update did not specify this privilege + all: { + application, + name: 'all', + actions: ['action:*'], + metadata: {}, + }, + read: { + application, + name: 'read', + // actions should only contain what was present in the update. The original actions are not persisted or merged here. + actions: ['action:readAction1', 'action:readAction4'], + metadata: {}, + }, + other: { + application, + name: 'other', + actions: ['action:otherAction1'], + metadata: {}, + } + } + }); + }); + }); +} diff --git a/x-pack/test/rbac_api_integration/apis/index.js b/x-pack/test/rbac_api_integration/apis/index.js index 0d89728aa04ff6..cf26e2e7cf4d85 100644 --- a/x-pack/test/rbac_api_integration/apis/index.js +++ b/x-pack/test/rbac_api_integration/apis/index.js @@ -6,6 +6,7 @@ export default function ({ loadTestFile }) { describe('apis RBAC', () => { + loadTestFile(require.resolve('./es')); loadTestFile(require.resolve('./privileges')); loadTestFile(require.resolve('./saved_objects')); }); diff --git a/x-pack/test/rbac_api_integration/config.js b/x-pack/test/rbac_api_integration/config.js index c2ff96adcff559..771d119c4385ab 100644 --- a/x-pack/test/rbac_api_integration/config.js +++ b/x-pack/test/rbac_api_integration/config.js @@ -25,6 +25,7 @@ export default async function ({ readConfigFile }) { servers: config.xpack.api.get('servers'), services: { es: EsProvider, + esSupertestWithoutAuth: config.xpack.api.get('services.esSupertestWithoutAuth'), supertest: config.kibana.api.get('services.supertest'), supertestWithoutAuth: config.xpack.api.get('services.supertestWithoutAuth'), esArchiver: config.kibana.functional.get('services.esArchiver'), From 69420349298d61acb69cfac23584af55069b908b Mon Sep 17 00:00:00 2001 From: kobelb Date: Thu, 5 Jul 2018 14:12:40 -0400 Subject: [PATCH 071/183] Switching the hard-coded resource from 'default' to * --- x-pack/plugins/security/common/constants.js | 2 +- .../security/public/views/management/edit_role.js | 10 +++++----- .../server/lib/authorization/check_privileges.js | 6 +++--- .../lib/authorization/check_privileges.test.js | 12 ++++++------ .../rbac_api_integration/apis/saved_objects/index.js | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/security/common/constants.js b/x-pack/plugins/security/common/constants.js index 2786acb56b0571..1b762d5f15acc6 100644 --- a/x-pack/plugins/security/common/constants.js +++ b/x-pack/plugins/security/common/constants.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export const DEFAULT_RESOURCE = 'default'; +export const ALL_RESOURCE = '*'; diff --git a/x-pack/plugins/security/public/views/management/edit_role.js b/x-pack/plugins/security/public/views/management/edit_role.js index 49caf22228fee6..226d04b7309964 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.js +++ b/x-pack/plugins/security/public/views/management/edit_role.js @@ -22,7 +22,7 @@ import { IndexPatternsProvider } from 'ui/index_patterns/index_patterns'; import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; import { checkLicenseError } from 'plugins/security/lib/check_license_error'; import { EDIT_ROLES_PATH, ROLES_PATH } from './management_urls'; -import { DEFAULT_RESOURCE } from '../../../common/constants'; +import { ALL_RESOURCE } from '../../../common/constants'; const getKibanaPrivileges = (applicationPrivileges, roleApplications, application) => { const kibanaPrivileges = applicationPrivileges.reduce((acc, p) => { @@ -34,10 +34,10 @@ const getKibanaPrivileges = (applicationPrivileges, roleApplications, applicatio return kibanaPrivileges; } - // we're filtering out privileges for non-default resources as well incase - // the roles were created in a future version + // we're filtering out privileges for non-all resources incase the roles were created in a future version const applications = roleApplications - .filter(roleApplication => roleApplication.application === application && roleApplication.resources.every(r => r === DEFAULT_RESOURCE)); + .filter(roleApplication => roleApplication.application === application) + .filter(roleApplication => !roleApplication.resources.some(resource => resource !== ALL_RESOURCE)); const assigned = _.uniq(_.flatten(_.pluck(applications, 'privileges'))); assigned.forEach(a => { @@ -63,7 +63,7 @@ const getRoleApplications = (kibanaPrivileges, currentRoleApplications = [], app newRoleApplications.push({ application, privileges: selectedPrivileges, - resources: [DEFAULT_RESOURCE] + resources: [ALL_RESOURCE] }); } diff --git a/x-pack/plugins/security/server/lib/authorization/check_privileges.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.js index 2ccc0f3e6e0ce9..a84b1cecdac792 100644 --- a/x-pack/plugins/security/server/lib/authorization/check_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.js @@ -5,7 +5,7 @@ */ import { getClient } from '../../../../../server/lib/get_client_shield'; -import { DEFAULT_RESOURCE } from '../../../common/constants'; +import { ALL_RESOURCE } from '../../../common/constants'; import { getVersionAction, getLoginAction } from '../privileges'; export const CHECK_PRIVILEGES_RESULT = { @@ -32,13 +32,13 @@ export function checkPrivilegesWithRequestFactory(server) { body: { applications: [{ application, - resources: [DEFAULT_RESOURCE], + resources: [ALL_RESOURCE], privileges }] } }); - const hasPrivileges = privilegeCheck.application[application][DEFAULT_RESOURCE]; + const hasPrivileges = privilegeCheck.application[application][ALL_RESOURCE]; // We include the login action in all privileges, so the existence of it and not the version privilege // lets us know that we're running in an incorrect configuration. Without the login privilege check, we wouldn't diff --git a/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js index 13299709577f04..80d7ea6b2b5fd3 100644 --- a/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js @@ -6,7 +6,7 @@ import { checkPrivilegesWithRequestFactory, CHECK_PRIVILEGES_RESULT } from './check_privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; -import { DEFAULT_RESOURCE } from '../../../common/constants'; +import { ALL_RESOURCE } from '../../../common/constants'; import { getLoginAction, getVersionAction } from '../privileges'; jest.mock('../../../../../server/lib/get_client_shield', () => ({ @@ -49,7 +49,7 @@ const mockApplicationPrivilegeResponse = ({ hasAllRequested, privileges, applica has_all_requested: hasAllRequested, application: { [application]: { - [DEFAULT_RESOURCE]: privileges + [ALL_RESOURCE]: privileges } } }; @@ -112,7 +112,7 @@ test(`returns authorized if they have all application privileges`, async () => { body: { applications: [{ application: defaultApplication, - resources: [DEFAULT_RESOURCE], + resources: [ALL_RESOURCE], privileges: [ getVersionAction(defaultVersion), getLoginAction(), ...privileges ] @@ -156,7 +156,7 @@ test(`returns unauthorized they have only one application privilege`, async () = body: { applications: [{ application: defaultApplication, - resources: [DEFAULT_RESOURCE], + resources: [ALL_RESOURCE], privileges: [ getVersionAction(defaultVersion), getLoginAction(), ...privileges ] @@ -227,7 +227,7 @@ describe('legacy fallback with no application privileges', () => { body: { applications: [{ application: defaultApplication, - resources: [DEFAULT_RESOURCE], + resources: [ALL_RESOURCE], privileges: [ getVersionAction(defaultVersion), getLoginAction(), ...privileges ] @@ -286,7 +286,7 @@ describe('legacy fallback with no application privileges', () => { body: { applications: [{ application: defaultApplication, - resources: [DEFAULT_RESOURCE], + resources: [ALL_RESOURCE], privileges: [ getVersionAction(defaultVersion), getLoginAction(), ...privileges ] diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/index.js b/x-pack/test/rbac_api_integration/apis/saved_objects/index.js index 05f06088751ea3..6336d4bcd47ecc 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/index.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/index.js @@ -44,7 +44,7 @@ export default function ({ loadTestFile, getService }) { { application: 'kibana', privileges: [ 'all' ], - resources: [ 'default' ] + resources: [ '*' ] } ] } @@ -59,7 +59,7 @@ export default function ({ loadTestFile, getService }) { { application: 'kibana', privileges: [ 'read' ], - resources: [ 'default' ] + resources: [ '*' ] } ] } From 19ddaea35e19d98f131ba1664d0302bfbdfd8e78 Mon Sep 17 00:00:00 2001 From: kobelb Date: Thu, 5 Jul 2018 15:08:57 -0400 Subject: [PATCH 072/183] Throw error before we execute a POST privilege call that won't work --- .../lib/privileges/equivalent_privileges.js | 11 ------ .../privileges/privilege_action_registry.js | 12 +++++-- .../privilege_action_registry.test.js | 34 +++++++++++++------ 3 files changed, 33 insertions(+), 24 deletions(-) delete mode 100644 x-pack/plugins/security/server/lib/privileges/equivalent_privileges.js diff --git a/x-pack/plugins/security/server/lib/privileges/equivalent_privileges.js b/x-pack/plugins/security/server/lib/privileges/equivalent_privileges.js deleted file mode 100644 index 35e2fd12af6d71..00000000000000 --- a/x-pack/plugins/security/server/lib/privileges/equivalent_privileges.js +++ /dev/null @@ -1,11 +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 { isEqual } from 'lodash'; - -export function equivalentPrivileges(p1, p2) { - return isEqual(p1, p2); -} diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js index 3112265a9b928e..87ca2105ff1b06 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { difference, isEqual } from 'lodash'; import { buildPrivilegeMap } from './privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; -import { equivalentPrivileges } from './equivalent_privileges'; export async function registerPrivilegesWithCluster(server) { const config = server.config(); @@ -26,11 +26,19 @@ export async function registerPrivilegesWithCluster(server) { // 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 (equivalentPrivileges(existingPrivileges, expectedPrivileges)) { + if (isEqual(existingPrivileges, expectedPrivileges)) { server.log(['security', 'debug'], `Kibana Privileges already registered with Elasticearch for ${application}`); return; } + // The ES privileges POST endpoint only allows us to add new privileges, or update specified privileges; it doesn't + // remove unspecified privileges. We don't currently have a need to remove privileges, as this would be a + // backwards compatibility issue, and we'd have to figure out how to migrate roles, so we're throwing an Error if we + // unintentionally get ourselves in this position. + if (difference(Object.keys(existingPrivileges[application]), Object.keys(expectedPrivileges[application])).length > 0) { + throw new Error(`Privileges are missing and can't be removed, currently.`); + } + server.log(['security', 'debug'], `Updated Kibana Privileges with Elasticearch for ${application}`); await callCluster('shield.postPrivileges', { body: expectedPrivileges diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js index 59b354fc247712..bd2a1ee552e168 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js @@ -107,13 +107,15 @@ const registerPrivilegesWithClusterTest = (description, { }; const createExpectErrorThrown = (mockServer, actualError) => { - return (expectedError) => { - expect(actualError).toBe(expectedError); + return (expectedErrorMessage) => { + expect(actualError).toBeDefined(); + expect(actualError).toBeInstanceOf(Error); + expect(actualError.message).toEqual(expectedErrorMessage); const application = settings['xpack.security.rbac.application'] || defaultApplication; expect(mockServer.log).toHaveBeenCalledWith( ['security', 'error'], - `Error registering Kibana Privileges with Elasticsearch for ${application}: ${expectedError.message}` + `Error registering Kibana Privileges with Elasticsearch for ${application}: ${expectedErrorMessage}` ); }; }; @@ -182,13 +184,26 @@ registerPrivilegesWithClusterTest(`updates privileges when simple top-level priv } }); -registerPrivilegesWithClusterTest(`updates privileges when we have two different simple top-level privileges`, { +registerPrivilegesWithClusterTest(`throws error when we have two different top-level privileges`, { expectedPrivileges: { notExpected: true }, existingPrivileges: { expected: true }, + assert: ({ expectErrorThrown }) => { + expectErrorThrown(`Privileges are missing and can't be removed, currently.`); + } +}); + +registerPrivilegesWithClusterTest(`updates privileges when we want to add a top-level privilege`, { + expectedPrivileges: { + expected: true, + new: false, + }, + existingPrivileges: { + expected: true, + }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges(); } @@ -288,15 +303,13 @@ registerPrivilegesWithClusterTest(`doesn't update privileges when nested propert } }); -const gettingPrivilegesError = new Error('Error getting privileges'); registerPrivilegesWithClusterTest(`throws and logs error when errors getting privileges`, { - throwErrorWhenGettingPrivileges: gettingPrivilegesError, + throwErrorWhenGettingPrivileges: new Error('Error getting privileges'), assert: ({ expectErrorThrown }) => { - expectErrorThrown(gettingPrivilegesError); + expectErrorThrown('Error getting privileges'); } }); -const puttingPrivilegesError = new Error('Error putting privileges'); registerPrivilegesWithClusterTest(`throws and logs error when errors putting privileges`, { expectedPrivileges: { kibana: { @@ -310,9 +323,8 @@ registerPrivilegesWithClusterTest(`throws and logs error when errors putting pri bar: true } }, - throwErrorWhenPuttingPrivileges: puttingPrivilegesError, + throwErrorWhenPuttingPrivileges: new Error('Error putting privileges'), assert: ({ expectErrorThrown }) => { - expectErrorThrown(puttingPrivilegesError); + expectErrorThrown('Error putting privileges'); } }); - From 1f4804122ee3678d7115a800c1b40af48875523b Mon Sep 17 00:00:00 2001 From: kobelb Date: Thu, 5 Jul 2018 16:38:30 -0400 Subject: [PATCH 073/183] Resolving issue when initially registering privileges --- .../lib/privileges/privilege_action_registry.js | 14 ++++++++++++-- .../privileges/privilege_action_registry.test.js | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js index 87ca2105ff1b06..93166fe28dd096 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js @@ -4,16 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { difference, isEqual } from 'lodash'; +import { difference, isEmpty, isEqual } from 'lodash'; import { buildPrivilegeMap } from './privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; + + export async function registerPrivilegesWithCluster(server) { const config = server.config(); const kibanaVersion = config.get('pkg.version'); const application = config.get('xpack.security.rbac.application'); const savedObjectTypes = server.savedObjects.types; + const shouldRemovePrivileges = (existingPrivileges, expectedPrivileges) => { + if (isEmpty(existingPrivileges)) { + return false; + } + + return difference(Object.keys(existingPrivileges[application]), Object.keys(expectedPrivileges[application])).length > 0; + }; + const expectedPrivileges = { [application]: buildPrivilegeMap(savedObjectTypes, application, kibanaVersion) }; @@ -35,7 +45,7 @@ export async function registerPrivilegesWithCluster(server) { // remove unspecified privileges. We don't currently have a need to remove privileges, as this would be a // backwards compatibility issue, and we'd have to figure out how to migrate roles, so we're throwing an Error if we // unintentionally get ourselves in this position. - if (difference(Object.keys(existingPrivileges[application]), Object.keys(expectedPrivileges[application])).length > 0) { + if (shouldRemovePrivileges(existingPrivileges, expectedPrivileges)) { throw new Error(`Privileges are missing and can't be removed, currently.`); } diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js index bd2a1ee552e168..c6e3428c563d01 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js @@ -128,6 +128,11 @@ const registerPrivilegesWithClusterTest = (description, { throw throwErrorWhenGettingPrivileges; } + // ES returns an empty object if we don't have any privileges + if (!existingPrivileges) { + return {}; + } + return { [defaultApplication]: existingPrivileges }; @@ -172,6 +177,16 @@ registerPrivilegesWithClusterTest(`passes saved object types, application and ki }, }); +registerPrivilegesWithClusterTest(`inserts privileges when we don't have any existing privileges`, { + expectedPrivileges: { + expected: true + }, + existingPrivileges: null, + assert: ({ expectUpdatedPrivileges }) => { + expectUpdatedPrivileges(); + } +}); + registerPrivilegesWithClusterTest(`updates privileges when simple top-level privileges values don't match`, { expectedPrivileges: { expected: true From a1720afb8bb64f1599fd5328d401411b7fa3f2ff Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 5 Jul 2018 16:45:40 -0400 Subject: [PATCH 074/183] fix merge 'conflict' --- .../security/public/views/management/edit_role/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security/public/views/management/edit_role/index.js b/x-pack/plugins/security/public/views/management/edit_role/index.js index 8f373f966f2a14..bd166e16a658ed 100644 --- a/x-pack/plugins/security/public/views/management/edit_role/index.js +++ b/x-pack/plugins/security/public/views/management/edit_role/index.js @@ -60,8 +60,8 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { return role.then(res => res.toJSON()); }, - kibanaApplicationPrivilege(ApplicationPrivilege, kbnUrl, Promise, Private) { - return ApplicationPrivilege.query().$promise + kibanaApplicationPrivilege(ApplicationPrivileges, kbnUrl, Promise, Private) { + return ApplicationPrivileges.query().$promise .then(privileges => privileges.map(p => p.toJSON())) .catch(checkLicenseError(kbnUrl, Promise, Private)); }, From 98ea1b57783f0f78266c92c6dcaca88c4526353c Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Fri, 6 Jul 2018 16:29:28 -0400 Subject: [PATCH 075/183] Logging legacy fallback deprecation warning on login (#20493) * Logging legacy fallback deprecation on login * Consolidation the privileges/authorization folder * Exposing rudimentary authorization service and fixing authenticate tests * Moving authorization services configuration to initAuthorization * Adding "actions" service exposed by the authorization * Fixing misspelling * Removing invalid and unused exports * Adding note about only adding privileges * Calling it initAuthorizationService * Throwing explicit validation error in actions.getSavedObjectAction * Deep freezing authorization service * Adding deepFreeze tests * Checking privileges in one call and cleaning up tests --- x-pack/plugins/security/index.js | 13 +- .../lib/__tests__/__fixtures__/server.js | 8 +- .../__snapshots__/actions.test.js.snap | 25 + .../check_privileges.test.js.snap | 4 +- .../__snapshots__/init.test.js.snap | 7 + .../server/lib/authorization/actions.js | 26 + .../server/lib/authorization/actions.test.js | 69 +++ .../lib/authorization/check_privileges.js | 121 ++-- .../authorization/check_privileges.test.js | 524 +++++++++--------- .../server/lib/authorization/deep_freeze.js | 20 + .../lib/authorization/deep_freeze.test.js | 97 ++++ .../server/lib/authorization/index.js | 10 + .../security/server/lib/authorization/init.js | 22 + .../server/lib/authorization/init.test.js | 64 +++ .../server/lib/authorization/privileges.js | 30 + .../register_privileges_with_cluster.js} | 6 +- .../register_privileges_with_cluster.test.js} | 19 +- .../security/server/lib/privileges/index.js | 8 - .../server/lib/privileges/privileges.js | 43 -- .../secure_saved_objects_client.js | 18 +- .../secure_saved_objects_client.test.js | 296 +++++----- .../routes/api/v1/__tests__/authenticate.js | 68 ++- .../server/routes/api/v1/authenticate.js | 10 + .../server/routes/api/v1/privileges.js | 6 +- .../apis/saved_objects/bulk_get.js | 2 +- .../apis/saved_objects/create.js | 8 +- .../apis/saved_objects/delete.js | 12 +- .../apis/saved_objects/find.js | 16 +- .../apis/saved_objects/get.js | 2 +- .../apis/saved_objects/update.js | 12 +- 30 files changed, 988 insertions(+), 578 deletions(-) create mode 100644 x-pack/plugins/security/server/lib/authorization/__snapshots__/actions.test.js.snap create mode 100644 x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap create mode 100644 x-pack/plugins/security/server/lib/authorization/actions.js create mode 100644 x-pack/plugins/security/server/lib/authorization/actions.test.js create mode 100644 x-pack/plugins/security/server/lib/authorization/deep_freeze.js create mode 100644 x-pack/plugins/security/server/lib/authorization/deep_freeze.test.js create mode 100644 x-pack/plugins/security/server/lib/authorization/index.js create mode 100644 x-pack/plugins/security/server/lib/authorization/init.js create mode 100644 x-pack/plugins/security/server/lib/authorization/init.test.js create mode 100644 x-pack/plugins/security/server/lib/authorization/privileges.js rename x-pack/plugins/security/server/lib/{privileges/privilege_action_registry.js => authorization/register_privileges_with_cluster.js} (96%) rename x-pack/plugins/security/server/lib/{privileges/privilege_action_registry.test.js => authorization/register_privileges_with_cluster.test.js} (94%) delete mode 100644 x-pack/plugins/security/server/lib/privileges/index.js delete mode 100644 x-pack/plugins/security/server/lib/privileges/privileges.js diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 6bcaf4e7aec46f..0e521589ac1410 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -17,11 +17,10 @@ import { authenticateFactory } from './server/lib/auth_redirect'; import { checkLicense } from './server/lib/check_license'; import { initAuthenticator } from './server/lib/authentication/authenticator'; import { initPrivilegesApi } from './server/routes/api/v1/privileges'; -import { checkPrivilegesWithRequestFactory } from './server/lib/authorization/check_privileges'; import { SecurityAuditLogger } from './server/lib/audit_logger'; import { AuditLogger } from '../../server/lib/audit_logger'; import { SecureSavedObjectsClient } from './server/lib/saved_objects_client/secure_saved_objects_client'; -import { registerPrivilegesWithCluster } from './server/lib/privileges'; +import { initAuthorizationService, registerPrivilegesWithCluster } from './server/lib/authorization'; import { watchStatusAndLicenseToInitialize } from './server/lib/watch_status_and_license_to_initialize'; export const security = (kibana) => new kibana.Plugin({ @@ -114,9 +113,11 @@ export const security = (kibana) => new kibana.Plugin({ server.auth.strategy('session', 'login', 'required'); const auditLogger = new SecurityAuditLogger(server.config(), new AuditLogger(server, 'security')); - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(server); - const { savedObjects } = server; + // exposes server.plugins.security.authorization + initAuthorizationService(server); + + const { savedObjects } = server; savedObjects.setScopedSavedObjectsClientFactory(({ request, }) => { @@ -130,7 +131,8 @@ export const security = (kibana) => new kibana.Plugin({ return new savedObjects.SavedObjectsClient(callWithRequestRepository); } - const checkPrivileges = checkPrivilegesWithRequest(request); + const { authorization } = server.plugins.security; + const checkPrivileges = authorization.checkPrivilegesWithRequest(request); const internalRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser); return new SecureSavedObjectsClient({ @@ -140,6 +142,7 @@ export const security = (kibana) => new kibana.Plugin({ checkPrivileges, auditLogger, savedObjectTypes: savedObjects.types, + actions: authorization.actions, }); }); diff --git a/x-pack/plugins/security/server/lib/__tests__/__fixtures__/server.js b/x-pack/plugins/security/server/lib/__tests__/__fixtures__/server.js index acc211eb0647ea..78a015bc141058 100644 --- a/x-pack/plugins/security/server/lib/__tests__/__fixtures__/server.js +++ b/x-pack/plugins/security/server/lib/__tests__/__fixtures__/server.js @@ -35,7 +35,13 @@ export function serverFixture() { security: { getUser: stub(), authenticate: stub(), - deauthenticate: stub() + deauthenticate: stub(), + authorization: { + checkPrivilegesWithRequest: stub(), + actions: { + login: 'stub-login-action', + }, + }, }, xpack_main: { diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/actions.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/actions.test.js.snap new file mode 100644 index 00000000000000..d65733feb37d46 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/actions.test.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#getSavedObjectAction() action of "" throws error 1`] = `"action is required and must be a string"`; + +exports[`#getSavedObjectAction() action of {} throws error 1`] = `"action is required and must be a string"`; + +exports[`#getSavedObjectAction() action of 1 throws error 1`] = `"action is required and must be a string"`; + +exports[`#getSavedObjectAction() action of null throws error 1`] = `"action is required and must be a string"`; + +exports[`#getSavedObjectAction() action of true throws error 1`] = `"action is required and must be a string"`; + +exports[`#getSavedObjectAction() action of undefined throws error 1`] = `"action is required and must be a string"`; + +exports[`#getSavedObjectAction() type of "" throws error 1`] = `"type is required and must be a string"`; + +exports[`#getSavedObjectAction() type of {} throws error 1`] = `"type is required and must be a string"`; + +exports[`#getSavedObjectAction() type of 1 throws error 1`] = `"type is required and must be a string"`; + +exports[`#getSavedObjectAction() type of null throws error 1`] = `"type is required and must be a string"`; + +exports[`#getSavedObjectAction() type of true throws error 1`] = `"type is required and must be a string"`; + +exports[`#getSavedObjectAction() type of undefined throws error 1`] = `"type is required and must be a string"`; diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap index e191d4b14b6f00..4c0c88a8f8313c 100644 --- a/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap @@ -1,3 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`throws error if missing version privilege and has login privilege 1`] = `"Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user."`; +exports[`with index privileges throws error if missing version privilege and has login privilege 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; + +exports[`with no index privileges throws error if missing version privilege and has login privilege 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap new file mode 100644 index 00000000000000..fd944032d2930f --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`deep freezes exposed service 1`] = `"Cannot delete property 'checkPrivilegesWithRequest' of #"`; + +exports[`deep freezes exposed service 2`] = `"Cannot add property foo, object is not extensible"`; + +exports[`deep freezes exposed service 3`] = `"Cannot assign to read only property 'login' of object '#'"`; diff --git a/x-pack/plugins/security/server/lib/authorization/actions.js b/x-pack/plugins/security/server/lib/authorization/actions.js new file mode 100644 index 00000000000000..432698a003cb35 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/actions.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isString } from 'lodash'; + +export function actionsFactory(config) { + const kibanaVersion = config.get('pkg.version'); + + return { + getSavedObjectAction(type, action) { + if (!type || !isString(type)) { + throw new Error('type is required and must be a string'); + } + + if (!action || !isString(action)) { + throw new Error('action is required and must be a string'); + } + + return `action:saved_objects/${type}/${action}`; + }, + login: `action:login`, + version: `version:${kibanaVersion}`, + }; +} diff --git a/x-pack/plugins/security/server/lib/authorization/actions.test.js b/x-pack/plugins/security/server/lib/authorization/actions.test.js new file mode 100644 index 00000000000000..17834438e17814 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/actions.test.js @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 './actions'; + +const createMockConfig = (settings = {}) => { + const mockConfig = { + get: jest.fn() + }; + + mockConfig.get.mockImplementation(key => settings[key]); + + return mockConfig; +}; + +describe('#login', () => { + test('returns action:login', () => { + const mockConfig = createMockConfig(); + + const actions = actionsFactory(mockConfig); + + expect(actions.login).toEqual('action: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).toEqual(`version:${version}`); + }); +}); + +describe('#getSavedObjectAction()', () => { + test('uses type and action to build action', () => { + const mockConfig = createMockConfig(); + const actions = actionsFactory(mockConfig); + const type = 'saved-object-type'; + const action = 'saved-object-action'; + + const result = actions.getSavedObjectAction(type, action); + + expect(result).toEqual(`action:saved_objects/${type}/${action}`); + }); + + [null, undefined, '', 1, true, {}].forEach(type => { + test(`type of ${JSON.stringify(type)} throws error`, () => { + const mockConfig = createMockConfig(); + const actions = actionsFactory(mockConfig); + + expect(() => actions.getSavedObjectAction(type, 'saved-object-action')).toThrowErrorMatchingSnapshot(); + }); + }); + + [null, undefined, '', 1, true, {}].forEach(action => { + test(`action of ${JSON.stringify(action)} throws error`, () => { + const mockConfig = createMockConfig(); + const actions = actionsFactory(mockConfig); + + expect(() => actions.getSavedObjectAction('saved-object-type', action)).toThrowErrorMatchingSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/security/server/lib/authorization/check_privileges.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.js index a84b1cecdac792..df8ee33ec29029 100644 --- a/x-pack/plugins/security/server/lib/authorization/check_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.js @@ -4,103 +4,82 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getClient } from '../../../../../server/lib/get_client_shield'; +import { uniq } from 'lodash'; import { ALL_RESOURCE } from '../../../common/constants'; -import { getVersionAction, getLoginAction } from '../privileges'; export const CHECK_PRIVILEGES_RESULT = { - UNAUTHORIZED: Symbol(), - AUTHORIZED: Symbol(), - LEGACY: Symbol(), + UNAUTHORIZED: Symbol('Unauthorized'), + AUTHORIZED: Symbol('Authorized'), + LEGACY: Symbol('Legacy'), }; -export function checkPrivilegesWithRequestFactory(server) { - const callWithRequest = getClient(server).callWithRequest; +export function checkPrivilegesWithRequestFactory(shieldClient, config, actions) { + const { callWithRequest } = shieldClient; - const config = server.config(); - const kibanaVersion = config.get('pkg.version'); const application = config.get('xpack.security.rbac.application'); const kibanaIndex = config.get('kibana.index'); - const loginAction = getLoginAction(); - const versionAction = getVersionAction(kibanaVersion); + const hasIncompatibileVersion = (applicationPrivilegesResponse) => { + return !applicationPrivilegesResponse[actions.version] && applicationPrivilegesResponse[actions.login]; + }; - return function checkPrivilegesWithRequest(request) { + const hasAllApplicationPrivileges = (applicationPrivilegesResponse) => { + return Object.values(applicationPrivilegesResponse).every(val => val === true); + }; - const checkApplicationPrivileges = async (privileges) => { - const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application, - resources: [ALL_RESOURCE], - privileges - }] - } - }); + const hasNoApplicationPrivileges = (applicationPrivilegesResponse) => { + return Object.values(applicationPrivilegesResponse).every(val => val === false); + }; - const hasPrivileges = privilegeCheck.application[application][ALL_RESOURCE]; + const hasLegacyPrivileges = (indexPrivilegesResponse) => { + return Object.values(indexPrivilegesResponse).includes(true); + }; - // We include the login action in all privileges, so the existence of it and not the version privilege - // lets us know that we're running in an incorrect configuration. Without the login privilege check, we wouldn't - // know whether the user just wasn't authorized for this instance of Kibana in general - if (!hasPrivileges[getVersionAction(kibanaVersion)] && hasPrivileges[getLoginAction()]) { - throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.'); - } + const determineResult = (applicationPrivilegesResponse, indexPrivilegesResponse) => { + if (hasAllApplicationPrivileges(applicationPrivilegesResponse)) { + return CHECK_PRIVILEGES_RESULT.AUTHORIZED; + } - return { - username: privilegeCheck.username, - hasAllRequested: privilegeCheck.has_all_requested, - privileges: hasPrivileges - }; - }; + if (hasNoApplicationPrivileges(applicationPrivilegesResponse) && hasLegacyPrivileges(indexPrivilegesResponse)) { + return CHECK_PRIVILEGES_RESULT.LEGACY; + } - const hasPrivilegesOnKibanaIndex = async () => { - const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { + return CHECK_PRIVILEGES_RESULT.UNAUTHORIZED; + }; + + return function checkPrivilegesWithRequest(request) { + + return async function checkPrivileges(privileges) { + const allApplicationPrivileges = uniq([actions.version, actions.login, ...privileges]); + const hasPrivilegesResponse = await callWithRequest(request, 'shield.hasPrivileges', { body: { + applications: [{ + application, + resources: [ALL_RESOURCE], + privileges: allApplicationPrivileges + }], index: [{ names: [kibanaIndex], privileges: ['create', 'delete', 'read', 'view_index_metadata'] - }] + }], } }); - return Object.values(privilegeCheck.index[kibanaIndex]).includes(true); - }; - - return async function checkPrivileges(privileges) { - const allPrivileges = [versionAction, loginAction, ...privileges]; - const applicationPrivilegesCheck = await checkApplicationPrivileges(allPrivileges); - - const username = applicationPrivilegesCheck.username; - - // We don't want to expose the version privilege to consumers, as it's an implementation detail only to detect version mismatch - const missing = Object.keys(applicationPrivilegesCheck.privileges) - .filter(p => !applicationPrivilegesCheck.privileges[p]) - .filter(p => p !== versionAction); - - if (applicationPrivilegesCheck.hasAllRequested) { - return { - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, - username, - missing, - }; - } + const applicationPrivilegesResponse = hasPrivilegesResponse.application[application][ALL_RESOURCE]; + const indexPrivilegesResponse = hasPrivilegesResponse.index[kibanaIndex]; - if (!applicationPrivilegesCheck.privileges[loginAction] && await hasPrivilegesOnKibanaIndex()) { - const msg = 'Relying on index privileges is deprecated and will be removed in Kibana 7.0'; - server.log(['warning', 'deprecated', 'security'], msg); - - return { - result: CHECK_PRIVILEGES_RESULT.LEGACY, - username, - missing, - }; + if (hasIncompatibileVersion(applicationPrivilegesResponse)) { + throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.'); } return { - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing, + result: determineResult(applicationPrivilegesResponse, indexPrivilegesResponse), + username: hasPrivilegesResponse.username, + + // we only return missing privileges that they're specifically checking for + missing: Object.keys(applicationPrivilegesResponse) + .filter(privilege => privileges.includes(privilege)) + .filter(privilege => !applicationPrivilegesResponse[privilege]) }; }; }; diff --git a/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js index 80d7ea6b2b5fd3..3c414665c8258d 100644 --- a/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js @@ -4,26 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ +import { uniq } from 'lodash'; import { checkPrivilegesWithRequestFactory, CHECK_PRIVILEGES_RESULT } from './check_privileges'; -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { ALL_RESOURCE } from '../../../common/constants'; -import { getLoginAction, getVersionAction } from '../privileges'; -jest.mock('../../../../../server/lib/get_client_shield', () => ({ - getClient: jest.fn() -})); +import { ALL_RESOURCE } from '../../../common/constants'; const defaultVersion = 'default-version'; const defaultApplication = 'default-application'; const defaultKibanaIndex = 'default-index'; const savedObjectTypes = ['foo-type', 'bar-type']; -const createMockServer = ({ settings = {} } = {}) => { - const mockServer = { - config: jest.fn().mockReturnValue({ - get: jest.fn() - }), - log: jest.fn() +const mockActions = { + login: 'mock-action:login', + version: 'mock-action:version', +}; + +const createMockConfig = ({ settings = {} } = {}) => { + const mockConfig = { + get: jest.fn() }; const defaultSettings = { @@ -32,280 +30,302 @@ const createMockServer = ({ settings = {} } = {}) => { 'kibana.index': defaultKibanaIndex, }; - mockServer.config().get.mockImplementation(key => { + mockConfig.get.mockImplementation(key => { return key in settings ? settings[key] : defaultSettings[key]; }); - mockServer.savedObjects = { - types: savedObjectTypes - }; - return mockServer; + return mockConfig; }; -const mockApplicationPrivilegeResponse = ({ hasAllRequested, privileges, application = defaultApplication, username = '' }) =>{ +const createMockShieldClient = (response) => { + const mockCallWithRequest = jest.fn(); + + mockCallWithRequest.mockImplementationOnce(async () => response); + return { - username: username, - has_all_requested: hasAllRequested, - application: { - [application]: { - [ALL_RESOURCE]: privileges - } - } + callWithRequest: mockCallWithRequest, }; }; -const mockKibanaIndexPrivilegesResponse = ({ privileges, index = defaultKibanaIndex }) => { - return { - index: { - [index]: privileges +const checkPrivilegesTest = ( + description, { + privileges, + applicationPrivilegesResponse, + indexPrivilegesResponse, + expectedResult, + expectErrorThrown, + }) => { + + test(description, async () => { + const username = 'foo-username'; + const mockConfig = createMockConfig(); + const mockShieldClient = createMockShieldClient({ + username, + application: { + [defaultApplication]: { + [ALL_RESOURCE]: applicationPrivilegesResponse + } + }, + index: { + [defaultKibanaIndex]: indexPrivilegesResponse + }, + }); + + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockShieldClient, mockConfig, mockActions); + const request = Symbol(); + const checkPrivileges = checkPrivilegesWithRequest(request); + + let actualResult; + let errorThrown = null; + try { + actualResult = await checkPrivileges(privileges); + } catch (err) { + errorThrown = err; } - }; -}; -const createMockCallWithRequest = (responses) => { - const mockCallWithRequest = jest.fn(); - getClient.mockReturnValue({ - callWithRequest: mockCallWithRequest - }); - for (const response of responses) { - mockCallWithRequest.mockImplementationOnce(async () => response); - } + expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application: defaultApplication, + resources: [ALL_RESOURCE], + privileges: uniq([ + mockActions.version, mockActions.login, ...privileges + ]) + }], + index: [{ + names: [ defaultKibanaIndex ], + privileges: ['create', 'delete', 'read', 'view_index_metadata'] + }], + } + }); - return mockCallWithRequest; -}; + if (expectedResult) { + expect(errorThrown).toBeNull(); + expect(actualResult).toEqual(expectedResult); + } -const deprecationMessage = 'Relying on index privileges is deprecated and will be removed in Kibana 7.0'; -const expectNoDeprecationLogged = (mockServer) => { - expect(mockServer.log).not.toHaveBeenCalledWith(['warning', 'deprecated', 'security'], deprecationMessage); -}; -const expectDeprecationLogged = (mockServer) => { - expect(mockServer.log).toHaveBeenCalledWith(['warning', 'deprecated', 'security'], deprecationMessage); + if (expectErrorThrown) { + expect(errorThrown).toMatchSnapshot(); + } + }); }; -test(`returns authorized if they have all application privileges`, async () => { - const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; - const username = 'foo-username'; - const mockServer = createMockServer(); - const mockCallWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: true, - privileges: { - [getVersionAction(defaultVersion)]: true, - [getLoginAction()]: true, - [privilege]: true, - }, - application: defaultApplication, - username, - }) - ]); - - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockServer); - const request = Symbol(); - const checkPrivileges = checkPrivilegesWithRequest(request); - const privileges = [privilege]; - const result = await checkPrivileges(privileges); - - expectNoDeprecationLogged(mockServer); - expect(mockCallWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [ALL_RESOURCE], - privileges: [ - getVersionAction(defaultVersion), getLoginAction(), ...privileges - ] - }] +describe(`with no index privileges`, () => { + const indexPrivilegesResponse = { + create: false, + delete: false, + read: false, + view_index_metadata: false, + }; + + checkPrivilegesTest('returns authorized if they have all application privileges', { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get` + ], + applicationPrivilegesResponse: { + [mockActions.version]: true, + [mockActions.login]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + }, + indexPrivilegesResponse, + expectedResult: { + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, + username: 'foo-username', + missing: [], } }); - expect(result).toEqual({ - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, - username, - missing: [], + + checkPrivilegesTest('returns unauthorized and missing application action when checking missing application action', { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get`, + `action:saved_objects/${savedObjectTypes[0]}/create`, + ], + applicationPrivilegesResponse: { + [mockActions.version]: true, + [mockActions.login]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[0]}/create`]: false, + }, + indexPrivilegesResponse, + expectedResult: { + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + username: 'foo-username', + missing: [`action:saved_objects/${savedObjectTypes[0]}/create`], + } }); -}); -test(`returns unauthorized they have only one application privilege`, async () => { - const privilege1 = `action:saved_objects/${savedObjectTypes[0]}/get`; - const privilege2 = `action:saved_objects/${savedObjectTypes[0]}/create`; - const username = 'foo-username'; - const mockServer = createMockServer(); - const mockCallWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionAction(defaultVersion)]: true, - [getLoginAction()]: true, - [privilege1]: true, - [privilege2]: false, - }, - application: defaultApplication, - username, - }) - ]); - - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockServer); - const request = Symbol(); - const checkPrivileges = checkPrivilegesWithRequest(request); - const privileges = [privilege1, privilege2]; - const result = await checkPrivileges(privileges); - - expectNoDeprecationLogged(mockServer); - expect(mockCallWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [ALL_RESOURCE], - privileges: [ - getVersionAction(defaultVersion), getLoginAction(), ...privileges - ] - }] + checkPrivilegesTest('returns unauthorized and missing login when checking missing login action', { + username: 'foo-username', + privileges: [ + mockActions.login + ], + applicationPrivilegesResponse: { + [mockActions.login]: false, + [mockActions.version]: false, + }, + indexPrivilegesResponse, + expectedResult: { + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + username: 'foo-username', + missing: [mockActions.login], + } + }); + + checkPrivilegesTest('returns unauthorized and missing version if checking missing version action', { + username: 'foo-username', + privileges: [ + mockActions.version + ], + applicationPrivilegesResponse: { + [mockActions.login]: false, + [mockActions.version]: false, + }, + indexPrivilegesResponse, + expectedResult: { + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + username: 'foo-username', + missing: [mockActions.version], } }); - expect(result).toEqual({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: [privilege2], + + checkPrivilegesTest('throws error if missing version privilege and has login privilege', { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get` + ], + applicationPrivilegesResponse: { + [mockActions.login]: true, + [mockActions.version]: false, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + }, + indexPrivilegesResponse, + expectErrorThrown: true }); }); -test(`throws error if missing version privilege and has login privilege`, async () => { - const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; - const mockServer = createMockServer(); - createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionAction(defaultVersion)]: false, - [getLoginAction()]: true, - [privilege]: true, - } - }) - ]); +describe(`with index privileges`, () => { + const indexPrivilegesResponse = { + create: true, + delete: true, + read: true, + view_index_metadata: true, + }; - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockServer); - const checkPrivileges = checkPrivilegesWithRequest({}); + checkPrivilegesTest('returns authorized if they have all application privileges', { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get` + ], + applicationPrivilegesResponse: { + [mockActions.version]: true, + [mockActions.login]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + }, + indexPrivilegesResponse, + expectedResult: { + result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, + username: 'foo-username', + missing: [], + } + }); - await expect(checkPrivileges([privilege])).rejects.toThrowErrorMatchingSnapshot(); - expectNoDeprecationLogged(mockServer); -}); + checkPrivilegesTest('returns unauthorized and missing application action when checking missing application action', { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get`, + `action:saved_objects/${savedObjectTypes[0]}/create`, + ], + applicationPrivilegesResponse: { + [mockActions.version]: true, + [mockActions.login]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[0]}/create`]: false, + }, + indexPrivilegesResponse, + expectedResult: { + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + username: 'foo-username', + missing: [`action:saved_objects/${savedObjectTypes[0]}/create`], + } + }); -describe('legacy fallback with no application privileges', () => { - test(`returns unauthorized if they have no privileges on the kibana index`, async () => { - const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; - const username = 'foo-username'; - const mockServer = createMockServer(); - const callWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionAction(defaultVersion)]: false, - [getLoginAction()]: false, - [privilege]: false, - }, - username, - }), - mockKibanaIndexPrivilegesResponse({ - privileges: { - create: false, - delete: false, - read: false, - view_index_metadata: false, - }, - }) - ]); - - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockServer); - const request = Symbol(); - const checkPrivileges = checkPrivilegesWithRequest(request); - const privileges = [privilege]; - const result = await checkPrivileges(privileges); + checkPrivilegesTest('returns legacy and missing login when checking missing login action', { + username: 'foo-username', + privileges: [ + mockActions.login + ], + applicationPrivilegesResponse: { + [mockActions.login]: false, + [mockActions.version]: false, + }, + indexPrivilegesResponse, + expectedResult: { + result: CHECK_PRIVILEGES_RESULT.LEGACY, + username: 'foo-username', + missing: [mockActions.login], + } + }); - expectNoDeprecationLogged(mockServer); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [ALL_RESOURCE], - privileges: [ - getVersionAction(defaultVersion), getLoginAction(), ...privileges - ] - }] - } - }); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - index: [{ - names: [ defaultKibanaIndex ], - privileges: ['create', 'delete', 'read', 'view_index_metadata'] - }] - } - }); - expect(result).toEqual({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: [getLoginAction(), ...privileges], - }); + checkPrivilegesTest('returns legacy and missing version if checking missing version action', { + username: 'foo-username', + privileges: [ + mockActions.version + ], + applicationPrivilegesResponse: { + [mockActions.login]: false, + [mockActions.version]: false, + }, + indexPrivilegesResponse, + expectedResult: { + result: CHECK_PRIVILEGES_RESULT.LEGACY, + username: 'foo-username', + missing: [mockActions.version], + } }); + checkPrivilegesTest('throws error if missing version privilege and has login privilege', { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get` + ], + applicationPrivilegesResponse: { + [mockActions.login]: true, + [mockActions.version]: false, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + }, + indexPrivilegesResponse, + expectErrorThrown: true + }); +}); + +describe('with no application privileges', () => { ['create', 'delete', 'read', 'view_index_metadata'].forEach(indexPrivilege => { - test(`returns legacy if they have ${indexPrivilege} privilege on the kibana index`, async () => { - const privilege = `action:saved_objects/${savedObjectTypes[0]}/get`; - const username = 'foo-username'; - const mockServer = createMockServer(); - const callWithRequest = createMockCallWithRequest([ - mockApplicationPrivilegeResponse({ - hasAllRequested: false, - privileges: { - [getVersionAction(defaultVersion)]: false, - [getLoginAction()]: false, - [privilege]: false, - }, - username, - }), - mockKibanaIndexPrivilegesResponse({ - privileges: { - create: false, - delete: false, - read: false, - view_index_metadata: false, - [indexPrivilege]: true - }, - }) - ]); - - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockServer); - const request = Symbol(); - const checkPrivileges = checkPrivilegesWithRequest(request); - const privileges = [privilege]; - const result = await checkPrivileges(privileges); - - expectDeprecationLogged(mockServer); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: defaultApplication, - resources: [ALL_RESOURCE], - privileges: [ - getVersionAction(defaultVersion), getLoginAction(), ...privileges - ] - }] - } - }); - expect(callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - index: [{ - names: [ defaultKibanaIndex ], - privileges: ['create', 'delete', 'read', 'view_index_metadata'] - }] - } - }); - expect(result).toEqual({ + checkPrivilegesTest(`returns legacy if they have ${indexPrivilege} privilege on the kibana index`, { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get` + ], + applicationPrivilegesResponse: { + [mockActions.version]: false, + [mockActions.login]: false, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + }, + indexPrivilegesResponse: { + create: false, + delete: false, + read: false, + view_index_metadata: false, + [indexPrivilege]: true + }, + expectedResult: { result: CHECK_PRIVILEGES_RESULT.LEGACY, - username, - missing: [getLoginAction(), ...privileges], - }); + username: 'foo-username', + missing: [`action:saved_objects/${savedObjectTypes[0]}/get`], + } }); }); }); diff --git a/x-pack/plugins/security/server/lib/authorization/deep_freeze.js b/x-pack/plugins/security/server/lib/authorization/deep_freeze.js new file mode 100644 index 00000000000000..0f9363cb410f6c --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/deep_freeze.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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/plugins/security/server/lib/authorization/deep_freeze.test.js b/x-pack/plugins/security/server/lib/authorization/deep_freeze.test.js new file mode 100644 index 00000000000000..dd227fa6269bff --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/deep_freeze.test.js @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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/plugins/security/server/lib/authorization/index.js b/x-pack/plugins/security/server/lib/authorization/index.js new file mode 100644 index 00000000000000..e0029a3caeafd1 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/index.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { CHECK_PRIVILEGES_RESULT } from './check_privileges'; +export { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; +export { buildPrivilegeMap } from './privileges'; +export { initAuthorizationService } from './init'; diff --git a/x-pack/plugins/security/server/lib/authorization/init.js b/x-pack/plugins/security/server/lib/authorization/init.js new file mode 100644 index 00000000000000..27ca3f7e84b556 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/init.js @@ -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 { actionsFactory } from './actions'; +import { checkPrivilegesWithRequestFactory } from './check_privileges'; +import { deepFreeze } from './deep_freeze'; +import { getClient } from '../../../../../server/lib/get_client_shield'; + +export function initAuthorizationService(server) { + const shieldClient = getClient(server); + const config = server.config(); + + const actions = actionsFactory(config); + + server.expose('authorization', deepFreeze({ + checkPrivilegesWithRequest: checkPrivilegesWithRequestFactory(shieldClient, config, actions), + actions + })); +} diff --git a/x-pack/plugins/security/server/lib/authorization/init.test.js b/x-pack/plugins/security/server/lib/authorization/init.test.js new file mode 100644 index 00000000000000..b72813809896f0 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/init.test.js @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { initAuthorizationService } from './init'; +import { actionsFactory } from './actions'; +import { checkPrivilegesWithRequestFactory } from './check_privileges'; +import { getClient } from '../../../../../server/lib/get_client_shield'; + +jest.mock('./check_privileges', () => ({ + checkPrivilegesWithRequestFactory: jest.fn(), +})); + +jest.mock('../../../../../server/lib/get_client_shield', () => ({ + getClient: jest.fn(), +})); + +jest.mock('./actions', () => ({ + actionsFactory: jest.fn(), +})); + +test(`calls server.expose with exposed services`, () => { + const mockConfig = Symbol(); + const mockServer = { + expose: jest.fn(), + config: jest.fn().mockReturnValue(mockConfig) + }; + const mockShieldClient = Symbol(); + getClient.mockReturnValue(mockShieldClient); + const mockCheckPrivilegesWithRequest = Symbol(); + checkPrivilegesWithRequestFactory.mockReturnValue(mockCheckPrivilegesWithRequest); + const mockActions = Symbol(); + actionsFactory.mockReturnValue(mockActions); + + initAuthorizationService(mockServer); + + expect(getClient).toHaveBeenCalledWith(mockServer); + expect(actionsFactory).toHaveBeenCalledWith(mockConfig); + expect(checkPrivilegesWithRequestFactory).toHaveBeenCalledWith(mockShieldClient, mockConfig, mockActions); + expect(mockServer.expose).toHaveBeenCalledWith('authorization', { + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + actions: mockActions, + }); +}); + +test(`deep freezes exposed service`, () => { + const mockConfig = Symbol(); + const mockServer = { + expose: jest.fn(), + config: jest.fn().mockReturnValue(mockConfig) + }; + actionsFactory.mockReturnValue({ + login: 'login', + }); + + initAuthorizationService(mockServer); + + const exposed = mockServer.expose.mock.calls[0][1]; + expect(() => delete exposed.checkPrivilegesWithRequest).toThrowErrorMatchingSnapshot(); + expect(() => exposed.foo = 'bar').toThrowErrorMatchingSnapshot(); + expect(() => exposed.actions.login = 'not-login').toThrowErrorMatchingSnapshot(); +}); diff --git a/x-pack/plugins/security/server/lib/authorization/privileges.js b/x-pack/plugins/security/server/lib/authorization/privileges.js new file mode 100644 index 00000000000000..9ded71662ed51c --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/privileges.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 buildPrivilegeMap(savedObjectTypes, application, actions) { + const buildSavedObjectsActions = (savedObjectActions) => { + return savedObjectTypes + .map(type => savedObjectActions.map(savedObjectAction => actions.getSavedObjectAction(type, savedObjectAction))) + .reduce((acc, types) => [...acc, ...types], []); + }; + + // the following list of privileges should only be added to, you can safely remove actions, but not privileges as + // it's a backwards compatibility issue and we'll have to at least adjust registerPrivilegesWithCluster to support it + return { + all: { + application, + name: 'all', + actions: [actions.version, 'action:*'], + metadata: {} + }, + read: { + application, + name: 'read', + actions: [actions.version, actions.login, ...buildSavedObjectsActions(['get', 'bulk_get', 'find'])], + metadata: {} + } + }; +} diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js similarity index 96% rename from x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js rename to x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js index 93166fe28dd096..432d4647dc1ee7 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js +++ b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js @@ -7,12 +7,14 @@ import { difference, isEmpty, isEqual } from 'lodash'; import { buildPrivilegeMap } from './privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; +import { actionsFactory } from './actions'; export async function registerPrivilegesWithCluster(server) { const config = server.config(); - const kibanaVersion = config.get('pkg.version'); + + const actions = actionsFactory(config); const application = config.get('xpack.security.rbac.application'); const savedObjectTypes = server.savedObjects.types; @@ -25,7 +27,7 @@ export async function registerPrivilegesWithCluster(server) { }; const expectedPrivileges = { - [application]: buildPrivilegeMap(savedObjectTypes, application, kibanaVersion) + [application]: buildPrivilegeMap(savedObjectTypes, application, actions) }; server.log(['security', 'debug'], `Registering Kibana Privileges with Elasticsearch for ${application}`); diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js similarity index 94% rename from x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js rename to x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js index c6e3428c563d01..9e4f2fa237e7a8 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js +++ b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js @@ -4,19 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { registerPrivilegesWithCluster } from './privilege_action_registry'; +import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; import { getClient } from '../../../../../server/lib/get_client_shield'; import { buildPrivilegeMap } from './privileges'; +import { actionsFactory } from './actions'; jest.mock('../../../../../server/lib/get_client_shield', () => ({ getClient: jest.fn(), })); jest.mock('./privileges', () => ({ buildPrivilegeMap: jest.fn(), })); +jest.mock('./actions', () => ({ + actionsFactory: jest.fn(), +})); const registerPrivilegesWithClusterTest = (description, { settings = {}, savedObjectTypes, + actions, expectedPrivileges, existingPrivileges, throwErrorWhenGettingPrivileges, @@ -143,6 +148,7 @@ const registerPrivilegesWithClusterTest = (description, { } }); + actionsFactory.mockReturnValue(actions); buildPrivilegeMap.mockReturnValue(expectedPrivileges); let error; @@ -163,7 +169,7 @@ const registerPrivilegesWithClusterTest = (description, { }); }; -registerPrivilegesWithClusterTest(`passes saved object types, application and kibanaVersion to buildPrivilegeMap`, { +registerPrivilegesWithClusterTest(`passes saved object types, application and actions to buildPrivilegeMap`, { settings: { 'pkg.version': 'foo-version', 'xpack.security.rbac.application': 'foo-application', @@ -172,8 +178,15 @@ registerPrivilegesWithClusterTest(`passes saved object types, application and ki 'foo-type', 'bar-type', ], + actions: { + login: 'mock-action:login', + version: 'mock-action:version', + }, assert: ({ mocks }) => { - expect(mocks.buildPrivilegeMap).toHaveBeenCalledWith(['foo-type', 'bar-type'], 'foo-application', 'foo-version'); + expect(mocks.buildPrivilegeMap).toHaveBeenCalledWith(['foo-type', 'bar-type'], 'foo-application', { + login: 'mock-action:login', + version: 'mock-action:version', + }); }, }); diff --git a/x-pack/plugins/security/server/lib/privileges/index.js b/x-pack/plugins/security/server/lib/privileges/index.js deleted file mode 100644 index f888dffa922dd6..00000000000000 --- a/x-pack/plugins/security/server/lib/privileges/index.js +++ /dev/null @@ -1,8 +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 { registerPrivilegesWithCluster } from './privilege_action_registry'; -export { buildPrivilegeMap, getLoginAction, getVersionAction } from './privileges'; diff --git a/x-pack/plugins/security/server/lib/privileges/privileges.js b/x-pack/plugins/security/server/lib/privileges/privileges.js deleted file mode 100644 index ea22f6e276594f..00000000000000 --- a/x-pack/plugins/security/server/lib/privileges/privileges.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export function getVersionAction(kibanaVersion) { - return `version:${kibanaVersion}`; -} - -export function getLoginAction() { - return `action:login`; -} - -export function buildPrivilegeMap(savedObjectTypes, application, kibanaVersion) { - const readSavedObjectsActions = buildSavedObjectsReadActions(savedObjectTypes); - - return { - all: { - application, - name: 'all', - actions: [getVersionAction(kibanaVersion), 'action:*'], - metadata: {} - }, - read: { - application, - name: 'read', - actions: [getVersionAction(kibanaVersion), getLoginAction(), ...readSavedObjectsActions], - metadata: {} - } - }; -} - -function buildSavedObjectsReadActions(savedObjectTypes) { - const readActions = ['get', 'bulk_get', 'find']; - return buildSavedObjectsActions(savedObjectTypes, readActions); -} - -function buildSavedObjectsActions(savedObjectTypes, actions) { - return savedObjectTypes - .map(type => actions.map(action => `action:saved_objects/${type}/${action}`)) - .reduce((acc, types) => [...acc, ...types], []); -} diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js index 419a379f7d0239..eb19f59c296932 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js @@ -7,10 +7,6 @@ import { get, uniq } from 'lodash'; import { CHECK_PRIVILEGES_RESULT } from '../authorization/check_privileges'; -const getPrivilege = (type, action) => { - return `action:saved_objects/${type}/${action}`; -}; - export class SecureSavedObjectsClient { constructor(options) { const { @@ -20,6 +16,7 @@ export class SecureSavedObjectsClient { checkPrivileges, auditLogger, savedObjectTypes, + actions, } = options; this.errors = errors; @@ -28,6 +25,7 @@ export class SecureSavedObjectsClient { this._checkPrivileges = checkPrivileges; this._auditLogger = auditLogger; this._savedObjectTypes = savedObjectTypes; + this._actions = actions; } async create(type, attributes = {}, options = {}) { @@ -94,9 +92,9 @@ export class SecureSavedObjectsClient { ); } - async _checkSavedObjectPrivileges(privileges) { + async _checkSavedObjectPrivileges(actions) { try { - return await this._checkPrivileges(privileges); + return await this._checkPrivileges(actions); } catch(error) { const { reason } = get(error, 'body.error', {}); throw this.errors.decorateGeneralError(error, reason); @@ -105,8 +103,8 @@ export class SecureSavedObjectsClient { async _execute(typeOrTypes, action, args, fn) { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; - const privileges = types.map(type => getPrivilege(type, action)); - const { result, username, missing } = await this._checkSavedObjectPrivileges(privileges); + const actions = types.map(type => this._actions.getSavedObjectAction(type, action)); + const { result, username, missing } = await this._checkSavedObjectPrivileges(actions); switch (result) { case CHECK_PRIVILEGES_RESULT.AUTHORIZED: @@ -116,7 +114,7 @@ export class SecureSavedObjectsClient { return await fn(this._callWithRequestRepository); case CHECK_PRIVILEGES_RESULT.UNAUTHORIZED: this._auditLogger.savedObjectsAuthorizationFailure(username, action, types, missing, args); - const msg = `Unable to ${action} ${types.sort().join(',')}, missing ${missing.sort().join(',')}`; + const msg = `Unable to ${action} ${[...types].sort().join(',')}, missing ${[...missing].sort().join(',')}`; throw this.errors.decorateForbiddenError(new Error(msg)); default: throw new Error('Unexpected result from hasPrivileges'); @@ -128,7 +126,7 @@ export class SecureSavedObjectsClient { // we have to filter for only their authorized types const types = this._savedObjectTypes; - const typesToPrivilegesMap = new Map(types.map(type => [type, getPrivilege(type, action)])); + const typesToPrivilegesMap = new Map(types.map(type => [type, this._actions.getSavedObjectAction(type, action)])); const { result, username, missing } = await this._checkSavedObjectPrivileges(Array.from(typesToPrivilegesMap.values())); if (result === CHECK_PRIVILEGES_RESULT.LEGACY) { diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js index 5e1b1f71165246..99656772c0df79 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js @@ -26,6 +26,14 @@ const createMockAuditLogger = () => { }; }; +const createMockActions = () => { + return { + getSavedObjectAction(type, action) { + return `mock-action:saved_objects/${type}/${action}`; + } + }; +}; + describe('#errors', () => { test(`assigns errors from constructor to .errors`, () => { const errors = Symbol(); @@ -44,15 +52,17 @@ describe('#create', () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/create`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'create')]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -62,31 +72,31 @@ describe('#create', () => { const type = 'foo'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, - missing: [ - `action:saved_objects/${type}/create` - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); const attributes = Symbol(); const options = Symbol(); await expect(client.create(type, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/create`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'create')]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'create', [type], - [`action:saved_objects/${type}/create`], + [mockActions.getSavedObjectAction(type, 'create')], { type, attributes, @@ -112,6 +122,7 @@ describe('#create', () => { internalRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const attributes = Symbol(); const options = Symbol(); @@ -135,18 +146,17 @@ describe('#create', () => { const mockRepository = { create: jest.fn().mockReturnValue(returnValue) }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.LEGACY, username, - missing: [ - `action:saved_objects/${type}/create` - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const attributes = Symbol(); const options = Symbol(); @@ -168,15 +178,17 @@ describe('#bulkCreate', () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); await expect(client.bulkCreate([{ type }])).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/bulk_create`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'bulk_create')]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -187,18 +199,20 @@ describe('#bulkCreate', () => { const type2 = 'bar'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ - `action:saved_objects/${type1}/bulk_create` + privileges[0] ], })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); const objects = [ { type: type1 }, @@ -210,15 +224,15 @@ describe('#bulkCreate', () => { await expect(client.bulkCreate(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); expect(mockCheckPrivileges).toHaveBeenCalledWith([ - `action:saved_objects/${type1}/bulk_create`, - `action:saved_objects/${type2}/bulk_create` + mockActions.getSavedObjectAction(type1, 'bulk_create'), + mockActions.getSavedObjectAction(type2, 'bulk_create'), ]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'bulk_create', - [type2, type1], - [`action:saved_objects/${type1}/bulk_create`], + [type1, type2], + [mockActions.getSavedObjectAction(type1, 'bulk_create')], { objects, options, @@ -243,6 +257,7 @@ describe('#bulkCreate', () => { internalRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const objects = [ { type: type1, otherThing: 'sup' }, @@ -269,19 +284,17 @@ describe('#bulkCreate', () => { const mockRepository = { bulkCreate: jest.fn().mockReturnValue(returnValue) }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.LEGACY, username, - missing: [ - `action:saved_objects/${type1}/bulk_create`, - `action:saved_objects/${type2}/bulk_create`, - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const objects = [ { type: type1, otherThing: 'sup' }, @@ -306,15 +319,17 @@ describe('#delete', () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/delete`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'delete')]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -324,30 +339,30 @@ describe('#delete', () => { const type = 'foo'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, - missing: [ - `action:saved_objects/${type}/delete` - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); const id = Symbol(); await expect(client.delete(type, id)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/delete`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'delete')]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'delete', [type], - [`action:saved_objects/${type}/delete`], + [mockActions.getSavedObjectAction(type, 'delete')], { type, id, @@ -372,6 +387,7 @@ describe('#delete', () => { internalRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const id = Symbol(); @@ -393,18 +409,17 @@ describe('#delete', () => { const mockRepository = { delete: jest.fn().mockReturnValue(returnValue) }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.LEGACY, username, - missing: [ - `action:saved_objects/${type}/delete` - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const id = Symbol(); @@ -426,15 +441,17 @@ describe('#find', () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'find')]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -445,31 +462,31 @@ describe('#find', () => { const username = Symbol(); const mockRepository = {}; const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, - missing: [ - `action:saved_objects/${type}/find` - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, internalRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); const options = { type }; await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'find')]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'find', [type], - [`action:saved_objects/${type}/find`], + [mockActions.getSavedObjectAction(type, 'find')], { options } @@ -482,69 +499,37 @@ describe('#find', () => { const type2 = 'bar'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: [ - `action:saved_objects/${type1}/find` - ], - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => { + return { + result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + username, + missing: [ + privileges[0] + ], + }; }); - const options = { type: [ type1, type2 ] }; - - await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type2, type1], - [`action:saved_objects/${type1}/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 mockRepository = {}; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: [ - `action:saved_objects/${type1}/find`, - `action:saved_objects/${type2}/find` - ], - })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, - repository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); const options = { type: [ type1, type2 ] }; await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([ + mockActions.getSavedObjectAction(type1, 'find'), + mockActions.getSavedObjectAction(type2, 'find') + ]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'find', - [type2, type1], - [`action:saved_objects/${type2}/find`, `action:saved_objects/${type1}/find`], + [type1, type2], + [mockActions.getSavedObjectAction(type1, 'find')], { options } @@ -568,6 +553,7 @@ describe('#find', () => { internalRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const options = { type }; @@ -588,18 +574,17 @@ describe('#find', () => { const mockRepository = { find: jest.fn().mockReturnValue(returnValue) }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.LEGACY, username, - missing: [ - `action:saved_objects/${type}/find` - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const options = { type }; @@ -622,17 +607,22 @@ describe('#find', () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, repository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, - savedObjectTypes: [type1, type2] + savedObjectTypes: [type1, type2], + actions: mockActions, }); await expect(client.find()).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([ + mockActions.getSavedObjectAction(type1, 'find'), + mockActions.getSavedObjectAction(type2, 'find'), + ]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -643,32 +633,34 @@ describe('#find', () => { const username = Symbol(); const mockRepository = {}; const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ - `action:saved_objects/${type}/find` + privileges[0] ], })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, repository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, - savedObjectTypes: [type] + savedObjectTypes: [type], + actions: mockActions, }); const options = Symbol(); await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'find')]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'find', [type], - [`action:saved_objects/${type}/find`], + [mockActions.getSavedObjectAction(type, 'find')], { options } @@ -684,27 +676,27 @@ describe('#find', () => { find: jest.fn().mockReturnValue(returnValue) }; const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.LEGACY, username, - missing: [ - `action:saved_objects/${type}/find` - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, callWithRequestRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, - savedObjectTypes: [type] + savedObjectTypes: [type], + actions: mockActions, }); const options = Symbol(); const result = await client.find(options); expect(result).toBe(returnValue); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'find')]); expect(mockRepository.find).toHaveBeenCalledWith(options); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -717,24 +709,29 @@ describe('#find', () => { find: jest.fn(), }; const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, missing: [ - `action:saved_objects/${type1}/find` + privileges[0] ] })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, internalRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, - savedObjectTypes: [type1, type2] + savedObjectTypes: [type1, type2], + actions: mockActions, }); await client.find(); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/find`, `action:saved_objects/${type2}/find`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([ + mockActions.getSavedObjectAction(type1, 'find'), + mockActions.getSavedObjectAction(type2, 'find'), + ]); expect(mockRepository.find).toHaveBeenCalledWith(expect.objectContaining({ type: [type2] })); @@ -757,7 +754,8 @@ describe('#find', () => { internalRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, - savedObjectTypes: [type] + savedObjectTypes: [type], + actions: createMockActions(), }); const options = Symbol(); @@ -781,15 +779,17 @@ describe('#bulkGet', () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckPrivileges).toHaveBeenCalledWith(['action:saved_objects/foo/bulk_get']); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'bulk_get')]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -800,18 +800,20 @@ describe('#bulkGet', () => { const type2 = 'bar'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, missing: [ - `action:saved_objects/${type1}/bulk_get` + privileges[0] ], })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); const objects = [ { type: type1 }, @@ -821,13 +823,16 @@ describe('#bulkGet', () => { await expect(client.bulkGet(objects)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type1}/bulk_get`, `action:saved_objects/${type2}/bulk_get`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([ + mockActions.getSavedObjectAction(type1, 'bulk_get'), + mockActions.getSavedObjectAction(type2, 'bulk_get'), + ]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'bulk_get', - [type2, type1], - [`action:saved_objects/${type1}/bulk_get`], + [type1, type2], + [mockActions.getSavedObjectAction(type1, 'bulk_get')], { objects } @@ -852,6 +857,7 @@ describe('#bulkGet', () => { internalRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const objects = [ { type: type1, id: 'foo-id' }, @@ -876,19 +882,17 @@ describe('#bulkGet', () => { const mockRepository = { bulkGet: jest.fn().mockReturnValue(returnValue) }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.LEGACY, username, - missing: [ - `action:saved_objects/${type1}/bulk_get`, - `action:saved_objects/${type2}/bulk_get` - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const objects = [ { type: type1, id: 'foo-id' }, @@ -912,15 +916,17 @@ describe('#get', () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/get`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'get')]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -930,30 +936,30 @@ describe('#get', () => { const type = 'foo'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, - missing: [ - `action:saved_objects/${type}/get` - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); const id = Symbol(); await expect(client.get(type, id)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/get`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'get')]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'get', [type], - [`action:saved_objects/${type}/get`], + [mockActions.getSavedObjectAction(type, 'get')], { type, id, @@ -978,6 +984,7 @@ describe('#get', () => { internalRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const id = Symbol(); @@ -999,18 +1006,17 @@ describe('#get', () => { const mockRepository = { get: jest.fn().mockReturnValue(returnValue) }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.LEGACY, username, - missing: [ - `action:saved_objects/${type}/get` - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const id = Symbol(); @@ -1031,15 +1037,17 @@ describe('#update', () => { throw new Error(); }); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/update`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'update')]); expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -1049,18 +1057,18 @@ describe('#update', () => { const type = 'foo'; const username = Symbol(); const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, username, - missing: [ - 'action:saved_objects/foo/update' - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); const client = new SecureSavedObjectsClient({ errors: mockErrors, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: mockActions, }); const id = Symbol(); const attributes = Symbol(); @@ -1068,13 +1076,13 @@ describe('#update', () => { await expect(client.update(type, id, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); - expect(mockCheckPrivileges).toHaveBeenCalledWith([`action:saved_objects/${type}/update`]); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'update')]); expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'update', [type], - [`action:saved_objects/${type}/update`], + [mockActions.getSavedObjectAction(type, 'update')], { type, id, @@ -1101,6 +1109,7 @@ describe('#update', () => { internalRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const id = Symbol(); const attributes = Symbol(); @@ -1126,18 +1135,17 @@ describe('#update', () => { const mockRepository = { update: jest.fn().mockReturnValue(returnValue) }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ + const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ result: CHECK_PRIVILEGES_RESULT.LEGACY, username, - missing: [ - 'action:saved_objects/foo/update' - ], + missing: privileges, })); const mockAuditLogger = createMockAuditLogger(); const client = new SecureSavedObjectsClient({ callWithRequestRepository: mockRepository, checkPrivileges: mockCheckPrivileges, auditLogger: mockAuditLogger, + actions: createMockActions(), }); const id = Symbol(); const attributes = Symbol(); diff --git a/x-pack/plugins/security/server/routes/api/v1/__tests__/authenticate.js b/x-pack/plugins/security/server/routes/api/v1/__tests__/authenticate.js index 0e4bcbf2f6a066..7604f4eff7b85d 100644 --- a/x-pack/plugins/security/server/routes/api/v1/__tests__/authenticate.js +++ b/x-pack/plugins/security/server/routes/api/v1/__tests__/authenticate.js @@ -15,6 +15,7 @@ import { AuthenticationResult } from '../../../../../server/lib/authentication/a import { BasicCredentials } from '../../../../../server/lib/authentication/providers/basic'; import { initAuthenticateApi } from '../authenticate'; import { DeauthenticationResult } from '../../../../lib/authentication/deauthentication_result'; +import { CHECK_PRIVILEGES_RESULT } from '../../../../lib/authorization'; describe('Authentication routes', () => { let serverStub; @@ -33,6 +34,7 @@ describe('Authentication routes', () => { let loginRoute; let request; let authenticateStub; + let checkPrivilegesWithRequestStub; beforeEach(() => { loginRoute = serverStub.route @@ -48,6 +50,7 @@ describe('Authentication routes', () => { authenticateStub = serverStub.plugins.security.authenticate.withArgs( sinon.match(BasicCredentials.decorateRequest({ headers: {} }, 'user', 'password')) ); + checkPrivilegesWithRequestStub = serverStub.plugins.security.authorization.checkPrivilegesWithRequest; }); it('correctly defines route.', async () => { @@ -124,18 +127,65 @@ describe('Authentication routes', () => { ); }); - it('returns user data if authentication succeed.', async () => { - const user = { username: 'user' }; - authenticateStub.returns( - Promise.resolve(AuthenticationResult.succeeded(user)) - ); + describe('authentication succeeds', () => { + const getDeprecationMessage = username => + `${username} relies on index privileges on the Kibana index. This is deprecated and will be removed in Kibana 7.0`; - await loginRoute.handler(request, replyStub); + it(`returns user data and doesn't log deprecation warning if checkPrivileges result is authorized.`, async () => { + const user = { username: 'user' }; + authenticateStub.returns( + Promise.resolve(AuthenticationResult.succeeded(user)) + ); + const checkPrivilegesStub = sinon.stub().returns({ result: CHECK_PRIVILEGES_RESULT.AUTHORIZED }); + checkPrivilegesWithRequestStub.returns(checkPrivilegesStub); - sinon.assert.notCalled(replyStub); - sinon.assert.calledOnce(replyStub.continue); - sinon.assert.calledWithExactly(replyStub.continue, { credentials: user }); + await loginRoute.handler(request, replyStub); + + sinon.assert.calledWithExactly(checkPrivilegesWithRequestStub, request); + sinon.assert.calledWithExactly(checkPrivilegesStub, [serverStub.plugins.security.authorization.actions.login]); + sinon.assert.neverCalledWith(serverStub.log, ['warning', 'deprecated', 'security'], getDeprecationMessage(user.username)); + sinon.assert.notCalled(replyStub); + sinon.assert.calledOnce(replyStub.continue); + sinon.assert.calledWithExactly(replyStub.continue, { credentials: user }); + }); + + it(`returns user data and logs deprecation warning if checkPrivileges result is legacy.`, async () => { + const user = { username: 'user' }; + authenticateStub.returns( + Promise.resolve(AuthenticationResult.succeeded(user)) + ); + const checkPrivilegesStub = sinon.stub().returns({ result: CHECK_PRIVILEGES_RESULT.LEGACY }); + checkPrivilegesWithRequestStub.returns(checkPrivilegesStub); + + await loginRoute.handler(request, replyStub); + + sinon.assert.calledWithExactly(checkPrivilegesWithRequestStub, request); + sinon.assert.calledWithExactly(checkPrivilegesStub, [serverStub.plugins.security.authorization.actions.login]); + sinon.assert.calledWith(serverStub.log, ['warning', 'deprecated', 'security'], getDeprecationMessage(user.username)); + sinon.assert.notCalled(replyStub); + sinon.assert.calledOnce(replyStub.continue); + sinon.assert.calledWithExactly(replyStub.continue, { credentials: user }); + }); + + it(`returns user data and doesn't log deprecation warning if checkPrivileges result is unauthorized.`, async () => { + const user = { username: 'user' }; + authenticateStub.returns( + Promise.resolve(AuthenticationResult.succeeded(user)) + ); + const checkPrivilegesStub = sinon.stub().returns({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED }); + checkPrivilegesWithRequestStub.returns(checkPrivilegesStub); + + await loginRoute.handler(request, replyStub); + + sinon.assert.calledWithExactly(checkPrivilegesWithRequestStub, request); + sinon.assert.calledWithExactly(checkPrivilegesStub, [serverStub.plugins.security.authorization.actions.login]); + sinon.assert.neverCalledWith(serverStub.log, ['warning', 'deprecated', 'security'], getDeprecationMessage(user.username)); + sinon.assert.notCalled(replyStub); + sinon.assert.calledOnce(replyStub.continue); + sinon.assert.calledWithExactly(replyStub.continue, { credentials: user }); + }); }); + }); describe('logout', () => { diff --git a/x-pack/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/plugins/security/server/routes/api/v1/authenticate.js index 9697774bfe8919..4b5d847b724b21 100644 --- a/x-pack/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/plugins/security/server/routes/api/v1/authenticate.js @@ -9,8 +9,10 @@ import Joi from 'joi'; import { wrapError } from '../../../lib/errors'; import { BasicCredentials } from '../../../../server/lib/authentication/providers/basic'; import { canRedirectRequest } from '../../../lib/can_redirect_request'; +import { CHECK_PRIVILEGES_RESULT } from '../../../../server/lib/authorization'; export function initAuthenticateApi(server) { + server.route({ method: 'POST', path: '/api/security/v1/login', @@ -35,6 +37,14 @@ export function initAuthenticateApi(server) { return reply(Boom.unauthorized(authenticationResult.error)); } + const { authorization } = server.plugins.security; + const checkPrivileges = authorization.checkPrivilegesWithRequest(request); + const privilegeCheck = await checkPrivileges([authorization.actions.login]); + if (privilegeCheck.result === CHECK_PRIVILEGES_RESULT.LEGACY) { + const msg = `${username} relies on index privileges on the Kibana index. This is deprecated and will be removed in Kibana 7.0`; + server.log(['warning', 'deprecated', 'security'], msg); + } + return reply.continue({ credentials: authenticationResult.user }); } catch(err) { return reply(wrapError(err)); diff --git a/x-pack/plugins/security/server/routes/api/v1/privileges.js b/x-pack/plugins/security/server/routes/api/v1/privileges.js index e3c830bf176885..375f6879972701 100644 --- a/x-pack/plugins/security/server/routes/api/v1/privileges.js +++ b/x-pack/plugins/security/server/routes/api/v1/privileges.js @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { buildPrivilegeMap } from '../../../lib/privileges/privileges'; +import { buildPrivilegeMap } from '../../../lib/authorization'; export function initPrivilegesApi(server) { const config = server.config(); - const kibanaVersion = config.get('pkg.version'); + const { authorization } = server.plugins.security; const application = config.get('xpack.security.rbac.application'); const savedObjectTypes = server.savedObjects.types; @@ -20,7 +20,7 @@ export function initPrivilegesApi(server) { // in Elasticsearch because our current thinking is that we'll associate additional structure/metadata // with our view of them to allow users to more efficiently edit privileges for roles, and serialize it // into a different structure for enforcement within Elasticsearch - const privileges = buildPrivilegeMap(savedObjectTypes, application, kibanaVersion); + const privileges = buildPrivilegeMap(savedObjectTypes, application, authorization.actions); reply(Object.values(privileges)); } }); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js b/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js index 6089aec5d89d02..a89f2b23f8f72e 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js @@ -70,7 +70,7 @@ export default function ({ getService }) { const expectRbacForbidden = resp => { //eslint-disable-next-line max-len - const missingActions = `action:login,action:saved_objects/config/bulk_get,action:saved_objects/dashboard/bulk_get,action:saved_objects/visualization/bulk_get`; + const missingActions = `action:saved_objects/config/bulk_get,action:saved_objects/dashboard/bulk_get,action:saved_objects/visualization/bulk_get`; expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/create.js b/x-pack/test/rbac_api_integration/apis/saved_objects/create.js index cff5da3502838c..0a37bf5a47a382 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/create.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/create.js @@ -29,11 +29,11 @@ export default function ({ getService }) { }); }; - const createExpectRbacForbidden = canLogin => resp => { + const expectRbacForbidden = resp => { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', - message: `Unable to create visualization, missing ${canLogin ? '' : 'action:login,'}action:saved_objects/visualization/create` + message: `Unable to create visualization, missing action:saved_objects/visualization/create` }); }; @@ -73,7 +73,7 @@ export default function ({ getService }) { tests: { default: { statusCode: 403, - response: createExpectRbacForbidden(false), + response: expectRbacForbidden, }, } }); @@ -138,7 +138,7 @@ export default function ({ getService }) { tests: { default: { statusCode: 403, - response: createExpectRbacForbidden(true), + response: expectRbacForbidden, }, } }); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js b/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js index b75ab5342c6baa..f1f693046f74be 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js @@ -25,11 +25,11 @@ export default function ({ getService }) { }); }; - const createExpectRbacForbidden = canLogin => resp => { + const expectRbacForbidden = resp => { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', - message: `Unable to delete dashboard, missing ${canLogin ? '' : 'action:login,'}action:saved_objects/dashboard/delete` + message: `Unable to delete dashboard, missing action:saved_objects/dashboard/delete` }); }; @@ -73,11 +73,11 @@ export default function ({ getService }) { tests: { actualId: { statusCode: 403, - response: createExpectRbacForbidden(false), + response: expectRbacForbidden, }, invalidId: { statusCode: 403, - response: createExpectRbacForbidden(false), + response: expectRbacForbidden, } } }); @@ -158,11 +158,11 @@ export default function ({ getService }) { tests: { actualId: { statusCode: 403, - response: createExpectRbacForbidden(true), + response: expectRbacForbidden, }, invalidId: { statusCode: 403, - response: createExpectRbacForbidden(true), + response: expectRbacForbidden, } } }); diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/find.js b/x-pack/test/rbac_api_integration/apis/saved_objects/find.js index 0498021e5daae2..26e43bba21cf05 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/find.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/find.js @@ -122,11 +122,11 @@ export default function ({ getService }) { }); }; - const createExpectRbacForbidden = (canLogin, type) => resp => { + const createExpectRbacForbidden = (type) => resp => { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', - message: `Unable to find ${type}, missing ${canLogin ? '' : 'action:login,'}action:saved_objects/${type}/find` + message: `Unable to find ${type}, missing action:saved_objects/${type}/find` }); }; @@ -202,22 +202,22 @@ export default function ({ getService }) { normal: { description: 'forbidden login and find visualization message', statusCode: 403, - response: createExpectRbacForbidden(false, 'visualization'), + response: createExpectRbacForbidden('visualization'), }, unknownType: { description: 'forbidden login and find wigwags message', statusCode: 403, - response: createExpectRbacForbidden(false, 'wigwags'), + response: createExpectRbacForbidden('wigwags'), }, pageBeyondTotal: { description: 'forbidden login and find visualization message', statusCode: 403, - response: createExpectRbacForbidden(false, 'visualization'), + response: createExpectRbacForbidden('visualization'), }, unknownSearchField: { description: 'forbidden login and find wigwags message', statusCode: 403, - response: createExpectRbacForbidden(false, 'wigwags'), + response: createExpectRbacForbidden('wigwags'), }, noType: { description: `forbidded can't find any types`, @@ -377,7 +377,7 @@ export default function ({ getService }) { unknownType: { description: 'forbidden find wigwags message', statusCode: 403, - response: createExpectRbacForbidden(true, 'wigwags'), + response: createExpectRbacForbidden('wigwags'), }, pageBeyondTotal: { description: 'empty result', @@ -387,7 +387,7 @@ export default function ({ getService }) { unknownSearchField: { description: 'forbidden find wigwags message', statusCode: 403, - response: createExpectRbacForbidden(true, 'wigwags'), + response: createExpectRbacForbidden('wigwags'), }, noType: { description: 'all objects', diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/get.js b/x-pack/test/rbac_api_integration/apis/saved_objects/get.js index ac7cc6b70f50a8..23c3c0b5aaa35c 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/get.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/get.js @@ -43,7 +43,7 @@ export default function ({ getService }) { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', - message: `Unable to get visualization, missing action:login,action:saved_objects/visualization/get` + message: `Unable to get visualization, missing action:saved_objects/visualization/get` }); }; diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/update.js b/x-pack/test/rbac_api_integration/apis/saved_objects/update.js index edcec1ffb61246..4b50600ba60c16 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/update.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/update.js @@ -38,11 +38,11 @@ export default function ({ getService }) { }); }; - const createExpectRbacForbidden = canLogin => resp => { + const expectRbacForbidden = resp => { expect(resp.body).to.eql({ statusCode: 403, error: 'Forbidden', - message: `Unable to update visualization, missing ${canLogin ? '' : 'action:login,'}action:saved_objects/visualization/update` + message: `Unable to update visualization, missing action:saved_objects/visualization/update` }); }; @@ -97,11 +97,11 @@ export default function ({ getService }) { tests: { exists: { statusCode: 403, - response: createExpectRbacForbidden(false), + response: expectRbacForbidden, }, doesntExist: { statusCode: 403, - response: createExpectRbacForbidden(false), + response: expectRbacForbidden, }, } }); @@ -182,11 +182,11 @@ export default function ({ getService }) { tests: { exists: { statusCode: 403, - response: createExpectRbacForbidden(true), + response: expectRbacForbidden, }, doesntExist: { statusCode: 403, - response: createExpectRbacForbidden(true), + response: expectRbacForbidden, }, } }); From 3c872341d718bfb12561f20b51dccf1f85eb930b Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Mon, 9 Jul 2018 09:43:27 -0400 Subject: [PATCH 076/183] improve space selector in left nav --- .../spaces/public/lib/spaces_manager.js | 9 ++- .../components/delete_spaces_button.js | 7 +- .../edit_space/manage_space_page.js | 3 + .../public/views/management/page_routes.js | 11 +-- .../spaces_grid/spaces_grid_page.js | 2 + .../public/views/nav_control/nav_control.js | 16 ++++- .../views/nav_control/nav_control_modal.js | 69 ++++++++++++++++--- .../spaces/server/lib/get_active_space.js | 57 +++++++++------ 8 files changed, 135 insertions(+), 39 deletions(-) diff --git a/x-pack/plugins/spaces/public/lib/spaces_manager.js b/x-pack/plugins/spaces/public/lib/spaces_manager.js index 77b1ea9b514caf..475f8c1b03b722 100644 --- a/x-pack/plugins/spaces/public/lib/spaces_manager.js +++ b/x-pack/plugins/spaces/public/lib/spaces_manager.js @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -export class SpacesManager { +import { EventEmitter } from 'events'; + +export class SpacesManager extends EventEmitter { constructor(httpAgent, chrome) { + super(); this._httpAgent = httpAgent; this._baseUrl = chrome.addBasePath(`/api/spaces/v1`); } @@ -35,4 +38,8 @@ export class SpacesManager { return await this._httpAgent .delete(`${this._baseUrl}/space/${space.id}`); } + + async requestRefresh() { + this.emit('request_refresh'); + } } diff --git a/x-pack/plugins/spaces/public/views/management/components/delete_spaces_button.js b/x-pack/plugins/spaces/public/views/management/components/delete_spaces_button.js index bfd38f1749dd83..6cb6d596a19964 100644 --- a/x-pack/plugins/spaces/public/views/management/components/delete_spaces_button.js +++ b/x-pack/plugins/spaces/public/views/management/components/delete_spaces_button.js @@ -61,7 +61,8 @@ export class DeleteSpacesButton extends Component { deleteSpaces = () => { const { spacesManager, - spaces + spaces, + spacesNavState, } = this.props; const deleteOperations = spaces.map(space => spacesManager.deleteSpace(space)); @@ -81,6 +82,9 @@ export class DeleteSpacesButton extends Component { if (this.props.onDelete) { this.props.onDelete(); } + + spacesNavState.refreshSpacesList(); + }) .catch(error => { const { @@ -95,5 +99,6 @@ export class DeleteSpacesButton extends Component { DeleteSpacesButton.propTypes = { spaces: PropTypes.array.isRequired, spacesManager: PropTypes.object.isRequired, + spacesNavState: PropTypes.object.isRequired, onDelete: PropTypes.func }; diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js b/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js index e1eb2de37e69fa..10e7c4040b76ad 100644 --- a/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js +++ b/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.js @@ -193,6 +193,7 @@ export class ManageSpacePage extends Component { @@ -290,6 +291,7 @@ export class ManageSpacePage extends Component { action .then(result => { + this.props.spacesNavState.refreshSpacesList(); toastNotifications.addSuccess(`Saved '${result.data.name}'`); window.location.hash = `#/management/spaces/list`; }) @@ -393,5 +395,6 @@ export class ManageSpacePage extends Component { ManageSpacePage.propTypes = { space: PropTypes.string, spacesManager: PropTypes.object, + spacesNavState: PropTypes.object.isRequired, breadcrumbs: PropTypes.array.isRequired, }; diff --git a/x-pack/plugins/spaces/public/views/management/page_routes.js b/x-pack/plugins/spaces/public/views/management/page_routes.js index e037ec46923c8f..4367d2b9282a6e 100644 --- a/x-pack/plugins/spaces/public/views/management/page_routes.js +++ b/x-pack/plugins/spaces/public/views/management/page_routes.js @@ -20,7 +20,7 @@ const reactRootNodeId = 'manageSpacesReactRoot'; routes.when('/management/spaces/list', { template, - controller: function ($scope, $http, chrome) { + controller: function ($scope, $http, chrome, spacesNavState) { const domNode = document.getElementById(reactRootNodeId); const spacesManager = new SpacesManager($http, chrome); @@ -28,6 +28,7 @@ routes.when('/management/spaces/list', { render(, domNode); // unmount react on controller destroy @@ -39,7 +40,7 @@ routes.when('/management/spaces/list', { routes.when('/management/spaces/create', { template, - controller: function ($scope, $http, chrome) { + controller: function ($scope, $http, chrome, spacesNavState) { const domNode = document.getElementById(reactRootNodeId); const spacesManager = new SpacesManager($http, chrome); @@ -47,6 +48,7 @@ routes.when('/management/spaces/create', { render(, domNode); // unmount react on controller destroy @@ -62,7 +64,7 @@ routes.when('/management/spaces/edit', { routes.when('/management/spaces/edit/:space', { template, - controller: function ($scope, $http, $route, chrome) { + controller: function ($scope, $http, $route, chrome, spacesNavState) { const domNode = document.getElementById(reactRootNodeId); const { space } = $route.current.params; @@ -75,6 +77,7 @@ routes.when('/management/spaces/edit/:space', { chrome={chrome} breadcrumbs={transformBreadcrumbs(routes.getBreadcrumbs())} spacesManager={spacesManager} + spacesNavState={spacesNavState} />, domNode); // unmount react on controller destroy @@ -86,4 +89,4 @@ routes.when('/management/spaces/edit/:space', { function transformBreadcrumbs(routeBreadcrumbs) { return routeBreadcrumbs.filter(b => b.id !== 'edit'); -} \ No newline at end of file +} diff --git a/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.js b/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.js index a039864aff3949..bb68e235053fea 100644 --- a/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.js +++ b/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.js @@ -70,6 +70,7 @@ export class SpacesGridPage extends Component { ); @@ -140,5 +141,6 @@ export class SpacesGridPage extends Component { SpacesGridPage.propTypes = { spacesManager: PropTypes.object.isRequired, + spacesNavState: PropTypes.object.isRequired, breadcrumbs: PropTypes.array.isRequired, }; diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control.js index 74be5a7f627dd5..4011fc47fc7dc6 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control.js +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control.js @@ -21,12 +21,14 @@ chromeNavControlsRegistry.register(constant({ template })); -const module = uiModules.get('spaces', ['kibana']); +const module = uiModules.get('spaces_nav', ['kibana']); + +let spacesManager; module.controller('spacesNavController', ($scope, $http, chrome, activeSpace) => { const domNode = document.getElementById(`spacesNavReactRoot`); - const spacesManager = new SpacesManager($http, chrome); + spacesManager = new SpacesManager($http, chrome); render(, domNode); @@ -36,3 +38,13 @@ module.controller('spacesNavController', ($scope, $http, chrome, activeSpace) => }); }); + +module.service('spacesNavState', () => { + return { + refreshSpacesList: () => { + if (spacesManager) { + spacesManager.requestRefresh(); + } + } + }; +}); diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js index 8de5ded601092e..5157621d2727e3 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { + EuiCallOut, + EuiText, EuiModal, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, EuiOverlayMask, EuiAvatar, + EuiSpacer, } from '@elastic/eui'; import { SpaceCards, SpaceAvatar } from '../components'; import { Notifier } from 'ui/notify'; @@ -21,6 +24,7 @@ export class NavControlModal extends Component { state = { isOpen: false, loading: false, + activeSpaceExists: true, spaces: [] }; @@ -28,7 +32,8 @@ export class NavControlModal extends Component { async loadSpaces() { const { - spacesManager + spacesManager, + activeSpace, } = this.props; this.setState({ @@ -36,8 +41,16 @@ export class NavControlModal extends Component { }); const spaces = await spacesManager.getSpaces(); + + let activeSpaceExists = this.state.activeSpaceExists; + if (activeSpace.valid) { + activeSpaceExists = !!spaces.find(space => space.id === this.props.activeSpace.space.id); + } + this.setState({ spaces, + activeSpaceExists, + isOpen: this.state.isOpen || !activeSpaceExists, loading: false }); } @@ -53,6 +66,14 @@ export class NavControlModal extends Component { this.notifier.error(error.message); } } + + this.loadSpaces(); + + if (this.props.spacesManager) { + this.props.spacesManager.on('request_refresh', () => { + this.loadSpaces(); + }); + } } render() { @@ -60,14 +81,7 @@ export class NavControlModal extends Component { if (this.state.isOpen) { modal = ( - - - Select a space - - - - - + {this.getActivePortal()} ); } @@ -86,6 +100,11 @@ export class NavControlModal extends Component { return null; } + // 0 or 1 spaces are available. Either either way, there is no need to render a space selection button + if (this.state.spaces.length < 2) { + return null; + } + if (activeSpace.valid && activeSpace.space) { return this.getButton( , @@ -112,6 +131,36 @@ export class NavControlModal extends Component { ); }; + getActivePortal = () => { + let callout; + + if (!this.state.activeSpaceExists) { + callout = ( + + + + Please choose a new Space to continue using Kibana + + + + + ); + + } + + return ( + + + Select a space + + + {callout} + + + + ); + } + togglePortal = () => { const isOpening = !this.state.isOpen; if (isOpening) { diff --git a/x-pack/plugins/spaces/server/lib/get_active_space.js b/x-pack/plugins/spaces/server/lib/get_active_space.js index 7fb7821e752741..c57957b1a6f938 100644 --- a/x-pack/plugins/spaces/server/lib/get_active_space.js +++ b/x-pack/plugins/spaces/server/lib/get_active_space.js @@ -7,45 +7,60 @@ import Boom from 'boom'; import { wrapError } from './errors'; import { getSpaceUrlContext } from '../../common/spaces_url_parser'; +import { DEFAULT_SPACE_ID } from '../../common/constants'; export async function getActiveSpace(savedObjectsClient, basePath) { const spaceContext = getSpaceUrlContext(basePath); - if (!spaceContext) { - return null; - } + let space; - let spaces; try { - const { - saved_objects: savedObjects - } = await savedObjectsClient.find({ - type: 'space', - search: `"${spaceContext}"`, - search_fields: ['urlContext'], - }); - - spaces = savedObjects || []; - } catch(e) { + if (spaceContext) { + space = await getSpaceByUrlContext(savedObjectsClient, spaceContext); + } else { + space = await getDefaultSpace(savedObjectsClient); + } + } + catch (e) { throw wrapError(e); } - if (spaces.length === 0) { + if (!space) { throw Boom.notFound( `The Space you requested could not be found. Please select a different Space to continue.` ); } - if (spaces.length > 1) { - const spaceNames = spaces.map(s => s.attributes.name).join(', '); + return { + id: space.id, + ...space.attributes + }; +} + +async function getDefaultSpace(savedObjectsClient) { + return savedObjectsClient.get('space', DEFAULT_SPACE_ID); +} + +async function getSpaceByUrlContext(savedObjectsClient, urlContext) { + const { + saved_objects: savedObjects + } = await savedObjectsClient.find({ + type: 'space', + search: `"${urlContext}"`, + search_fields: ['urlContext'], + }); + + if (savedObjects.length === 0) { + return null; + } + + if (savedObjects.length > 1) { + const spaceNames = savedObjects.map(s => s.attributes.name).join(', '); throw Boom.badRequest( `Multiple Spaces share this URL Context: (${spaceNames}). Please correct this in the Management Section.` ); } - return { - id: spaces[0].id, - ...spaces[0].attributes - }; + return savedObjects[0]; } From 7cef6061ab9deeb016f047d3fb7da774d6f8d546 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Tue, 10 Jul 2018 11:06:38 -0400 Subject: [PATCH 077/183] Deriving application from Kibana index (#20614) * Specifying the application on the "authorization service" * Moving watchStatusAndLicenseToInitialize to be below initAuthorizationService * Using short-hand propery assignment --- x-pack/plugins/security/index.js | 19 ++++---- .../server/lib/__tests__/validate_config.js | 16 ------- .../__snapshots__/init.test.js.snap | 2 + .../lib/authorization/check_privileges.js | 3 +- .../authorization/check_privileges.test.js | 9 ++-- .../security/server/lib/authorization/init.js | 6 ++- .../server/lib/authorization/init.test.js | 27 +++++++++-- .../register_privileges_with_cluster.js | 10 ++-- .../register_privileges_with_cluster.test.js | 47 +++++++++---------- .../security/server/lib/validate_config.js | 8 ---- .../server/routes/api/v1/privileges.js | 4 +- .../apis/privileges/index.js | 4 +- .../apis/saved_objects/index.js | 5 +- 13 files changed, 74 insertions(+), 86 deletions(-) diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 0e521589ac1410..8e0c5ad68c64c7 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -76,10 +76,11 @@ export const security = (kibana) => new kibana.Plugin({ injectDefaultVars: function (server) { const config = server.config(); + const { authorization } = server.plugins.security; return { secureCookies: config.get('xpack.security.secureCookies'), sessionTimeout: config.get('xpack.security.sessionTimeout'), - rbacApplication: config.get('xpack.security.rbac.application'), + rbacApplication: authorization.application, }; } }, @@ -97,12 +98,6 @@ export const security = (kibana) => new kibana.Plugin({ // to re-compute the license check results for this plugin xpackInfoFeature.registerLicenseCheckResultsGenerator(checkLicense); - watchStatusAndLicenseToInitialize(xpackMainPlugin, plugin, async (license) => { - if (license.allowRbac) { - await registerPrivilegesWithCluster(server); - } - }); - validateConfig(config, message => server.log(['security', 'warning'], message)); // Create a Hapi auth scheme that should be applied to each request. @@ -112,11 +107,17 @@ export const security = (kibana) => new kibana.Plugin({ // automatically assigned to all routes that don't contain an auth config. server.auth.strategy('session', 'login', 'required'); - const auditLogger = new SecurityAuditLogger(server.config(), new AuditLogger(server, 'security')); - // exposes server.plugins.security.authorization initAuthorizationService(server); + watchStatusAndLicenseToInitialize(xpackMainPlugin, plugin, async (license) => { + if (license.allowRbac) { + await registerPrivilegesWithCluster(server); + } + }); + + const auditLogger = new SecurityAuditLogger(server.config(), new AuditLogger(server, 'security')); + const { savedObjects } = server; savedObjects.setScopedSavedObjectsClientFactory(({ request, diff --git a/x-pack/plugins/security/server/lib/__tests__/validate_config.js b/x-pack/plugins/security/server/lib/__tests__/validate_config.js index e8ba58cf23aec3..26d84f6f5559b4 100644 --- a/x-pack/plugins/security/server/lib/__tests__/validate_config.js +++ b/x-pack/plugins/security/server/lib/__tests__/validate_config.js @@ -75,20 +75,4 @@ describe('Validate config', function () { sinon.assert.calledWith(config.set, 'xpack.security.secureCookies', true); sinon.assert.notCalled(log); }); - - it('should throw error if xpack.security.rbac.application is the default when kibana.index is set', function () { - // other valid keys that we need - config.get.withArgs('server.ssl.key').returns('foo'); - config.get.withArgs('server.ssl.certificate').returns('bar'); - config.get.withArgs('xpack.security.encryptionKey').returns(validKey); - - config.get.withArgs('kibana.index').returns('notDefaultIndex'); - config.get.withArgs('xpack.security.rbac.application').returns('defaultApplication'); - config.getDefault.withArgs('kibana.index').returns('defaultIndex'); - config.getDefault.withArgs('xpack.security.rbac.application').returns('defaultApplication'); - - expect(() => validateConfig(config, log)).to.throwError(); - - sinon.assert.notCalled(log); - }); }); diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap index fd944032d2930f..c65b0d2d6ae391 100644 --- a/x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap @@ -5,3 +5,5 @@ exports[`deep freezes exposed service 1`] = `"Cannot delete property 'checkPrivi exports[`deep freezes exposed service 2`] = `"Cannot add property foo, object is not extensible"`; exports[`deep freezes exposed service 3`] = `"Cannot assign to read only property 'login' of object '#'"`; + +exports[`deep freezes exposed service 4`] = `"Cannot assign to read only property 'application' of object '#'"`; diff --git a/x-pack/plugins/security/server/lib/authorization/check_privileges.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.js index df8ee33ec29029..594966e5b3e4ef 100644 --- a/x-pack/plugins/security/server/lib/authorization/check_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.js @@ -13,10 +13,9 @@ export const CHECK_PRIVILEGES_RESULT = { LEGACY: Symbol('Legacy'), }; -export function checkPrivilegesWithRequestFactory(shieldClient, config, actions) { +export function checkPrivilegesWithRequestFactory(shieldClient, config, actions, application) { const { callWithRequest } = shieldClient; - const application = config.get('xpack.security.rbac.application'); const kibanaIndex = config.get('kibana.index'); const hasIncompatibileVersion = (applicationPrivilegesResponse) => { diff --git a/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js index 3c414665c8258d..edf3b42d11c28f 100644 --- a/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js @@ -9,8 +9,8 @@ import { checkPrivilegesWithRequestFactory, CHECK_PRIVILEGES_RESULT } from './ch import { ALL_RESOURCE } from '../../../common/constants'; +const application = 'kibana-our_application'; const defaultVersion = 'default-version'; -const defaultApplication = 'default-application'; const defaultKibanaIndex = 'default-index'; const savedObjectTypes = ['foo-type', 'bar-type']; @@ -26,7 +26,6 @@ const createMockConfig = ({ settings = {} } = {}) => { const defaultSettings = { 'pkg.version': defaultVersion, - 'xpack.security.rbac.application': defaultApplication, 'kibana.index': defaultKibanaIndex, }; @@ -63,7 +62,7 @@ const checkPrivilegesTest = ( const mockShieldClient = createMockShieldClient({ username, application: { - [defaultApplication]: { + [application]: { [ALL_RESOURCE]: applicationPrivilegesResponse } }, @@ -72,7 +71,7 @@ const checkPrivilegesTest = ( }, }); - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockShieldClient, mockConfig, mockActions); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockShieldClient, mockConfig, mockActions, application); const request = Symbol(); const checkPrivileges = checkPrivilegesWithRequest(request); @@ -88,7 +87,7 @@ const checkPrivilegesTest = ( expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { body: { applications: [{ - application: defaultApplication, + application, resources: [ALL_RESOURCE], privileges: uniq([ mockActions.version, mockActions.login, ...privileges diff --git a/x-pack/plugins/security/server/lib/authorization/init.js b/x-pack/plugins/security/server/lib/authorization/init.js index 27ca3f7e84b556..f99bf6d25d26fe 100644 --- a/x-pack/plugins/security/server/lib/authorization/init.js +++ b/x-pack/plugins/security/server/lib/authorization/init.js @@ -14,9 +14,11 @@ export function initAuthorizationService(server) { const config = server.config(); const actions = actionsFactory(config); + const application = `kibana-${config.get('kibana.index')}`; server.expose('authorization', deepFreeze({ - checkPrivilegesWithRequest: checkPrivilegesWithRequestFactory(shieldClient, config, actions), - actions + actions, + application, + checkPrivilegesWithRequest: checkPrivilegesWithRequestFactory(shieldClient, config, actions, application), })); } diff --git a/x-pack/plugins/security/server/lib/authorization/init.test.js b/x-pack/plugins/security/server/lib/authorization/init.test.js index b72813809896f0..d70e08934c131f 100644 --- a/x-pack/plugins/security/server/lib/authorization/init.test.js +++ b/x-pack/plugins/security/server/lib/authorization/init.test.js @@ -21,8 +21,21 @@ jest.mock('./actions', () => ({ actionsFactory: jest.fn(), })); +const createMockConfig = (settings = {}) => { + const mockConfig = { + get: jest.fn() + }; + + mockConfig.get.mockImplementation(key => settings[key]); + + return mockConfig; +}; + test(`calls server.expose with exposed services`, () => { - const mockConfig = Symbol(); + const kibanaIndex = '.a-kibana-index'; + const mockConfig = createMockConfig({ + 'kibana.index': kibanaIndex + }); const mockServer = { expose: jest.fn(), config: jest.fn().mockReturnValue(mockConfig) @@ -33,20 +46,25 @@ test(`calls server.expose with exposed services`, () => { checkPrivilegesWithRequestFactory.mockReturnValue(mockCheckPrivilegesWithRequest); const mockActions = Symbol(); actionsFactory.mockReturnValue(mockActions); + mockConfig.get.mock; initAuthorizationService(mockServer); + const application = `kibana-${kibanaIndex}`; expect(getClient).toHaveBeenCalledWith(mockServer); expect(actionsFactory).toHaveBeenCalledWith(mockConfig); - expect(checkPrivilegesWithRequestFactory).toHaveBeenCalledWith(mockShieldClient, mockConfig, mockActions); + expect(checkPrivilegesWithRequestFactory).toHaveBeenCalledWith(mockShieldClient, mockConfig, mockActions, application); expect(mockServer.expose).toHaveBeenCalledWith('authorization', { - checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, actions: mockActions, + application, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, }); }); test(`deep freezes exposed service`, () => { - const mockConfig = Symbol(); + const mockConfig = createMockConfig({ + 'kibana.index': '' + }); const mockServer = { expose: jest.fn(), config: jest.fn().mockReturnValue(mockConfig) @@ -61,4 +79,5 @@ test(`deep freezes exposed service`, () => { expect(() => delete exposed.checkPrivilegesWithRequest).toThrowErrorMatchingSnapshot(); expect(() => exposed.foo = 'bar').toThrowErrorMatchingSnapshot(); expect(() => exposed.actions.login = 'not-login').toThrowErrorMatchingSnapshot(); + expect(() => exposed.application = 'changed').toThrowErrorMatchingSnapshot(); }); diff --git a/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js index 432d4647dc1ee7..826cdab4b42040 100644 --- a/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js +++ b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js @@ -7,16 +7,12 @@ import { difference, isEmpty, isEqual } from 'lodash'; import { buildPrivilegeMap } from './privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; -import { actionsFactory } from './actions'; - - export async function registerPrivilegesWithCluster(server) { - const config = server.config(); - const actions = actionsFactory(config); - const application = config.get('xpack.security.rbac.application'); - const savedObjectTypes = server.savedObjects.types; + const { authorization } = server.plugins.security; + const { types: savedObjectTypes } = server.savedObjects; + const { actions, application } = authorization; const shouldRemovePrivileges = (existingPrivileges, expectedPrivileges) => { if (isEmpty(existingPrivileges)) { diff --git a/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js index 9e4f2fa237e7a8..f326d85fdeee3f 100644 --- a/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js +++ b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js @@ -7,21 +7,16 @@ import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; import { getClient } from '../../../../../server/lib/get_client_shield'; import { buildPrivilegeMap } from './privileges'; -import { actionsFactory } from './actions'; jest.mock('../../../../../server/lib/get_client_shield', () => ({ getClient: jest.fn(), })); jest.mock('./privileges', () => ({ buildPrivilegeMap: jest.fn(), })); -jest.mock('./actions', () => ({ - actionsFactory: jest.fn(), -})); const registerPrivilegesWithClusterTest = (description, { settings = {}, savedObjectTypes, - actions, expectedPrivileges, existingPrivileges, throwErrorWhenGettingPrivileges, @@ -37,7 +32,7 @@ const registerPrivilegesWithClusterTest = (description, { }; const defaultVersion = 'default-version'; - const defaultApplication = 'default-application'; + const application = 'default-application'; const createMockServer = () => { const mockServer = { @@ -45,11 +40,18 @@ const registerPrivilegesWithClusterTest = (description, { get: jest.fn(), }), log: jest.fn(), + plugins: { + security: { + authorization: { + actions: Symbol(), + application + } + } + } }; const defaultSettings = { 'pkg.version': defaultVersion, - 'xpack.security.rbac.application': defaultApplication, }; mockServer.config().get.mockImplementation(key => { @@ -68,18 +70,17 @@ const registerPrivilegesWithClusterTest = (description, { expect(error).toBeUndefined(); expect(mockCallWithInternalUser).toHaveBeenCalledTimes(2); expect(mockCallWithInternalUser).toHaveBeenCalledWith('shield.getPrivilege', { - privilege: defaultApplication, + privilege: application, }); expect(mockCallWithInternalUser).toHaveBeenCalledWith( 'shield.postPrivileges', { body: { - [defaultApplication]: privileges + [application]: privileges }, } ); - const application = settings['xpack.security.rbac.application'] || defaultApplication; expect(mockServer.log).toHaveBeenCalledWith( ['security', 'debug'], `Registering Kibana Privileges with Elasticsearch for ${application}` @@ -96,10 +97,9 @@ const registerPrivilegesWithClusterTest = (description, { expect(error).toBeUndefined(); expect(mockCallWithInternalUser).toHaveBeenCalledTimes(1); expect(mockCallWithInternalUser).toHaveBeenLastCalledWith('shield.getPrivilege', { - privilege: defaultApplication + privilege: application }); - const application = settings['xpack.security.rbac.application'] || defaultApplication; expect(mockServer.log).toHaveBeenCalledWith( ['security', 'debug'], `Registering Kibana Privileges with Elasticsearch for ${application}` @@ -117,7 +117,6 @@ const registerPrivilegesWithClusterTest = (description, { expect(actualError).toBeInstanceOf(Error); expect(actualError.message).toEqual(expectedErrorMessage); - const application = settings['xpack.security.rbac.application'] || defaultApplication; expect(mockServer.log).toHaveBeenCalledWith( ['security', 'error'], `Error registering Kibana Privileges with Elasticsearch for ${application}: ${expectedErrorMessage}` @@ -139,7 +138,7 @@ const registerPrivilegesWithClusterTest = (description, { } return { - [defaultApplication]: existingPrivileges + [application]: existingPrivileges }; }) .mockImplementationOnce(async () => { @@ -148,7 +147,6 @@ const registerPrivilegesWithClusterTest = (description, { } }); - actionsFactory.mockReturnValue(actions); buildPrivilegeMap.mockReturnValue(expectedPrivileges); let error; @@ -163,7 +161,8 @@ const registerPrivilegesWithClusterTest = (description, { expectDidntUpdatePrivileges: createExpectDidntUpdatePrivileges(mockServer, mockCallWithInternalUser, error), expectErrorThrown: createExpectErrorThrown(mockServer, error), mocks: { - buildPrivilegeMap + buildPrivilegeMap, + server: mockServer, } }); }); @@ -171,22 +170,18 @@ const registerPrivilegesWithClusterTest = (description, { registerPrivilegesWithClusterTest(`passes saved object types, application and actions to buildPrivilegeMap`, { settings: { - 'pkg.version': 'foo-version', - 'xpack.security.rbac.application': 'foo-application', + 'pkg.version': 'foo-version' }, savedObjectTypes: [ 'foo-type', 'bar-type', ], - actions: { - login: 'mock-action:login', - version: 'mock-action:version', - }, assert: ({ mocks }) => { - expect(mocks.buildPrivilegeMap).toHaveBeenCalledWith(['foo-type', 'bar-type'], 'foo-application', { - login: 'mock-action:login', - version: 'mock-action:version', - }); + expect(mocks.buildPrivilegeMap).toHaveBeenCalledWith( + ['foo-type', 'bar-type'], + mocks.server.plugins.security.authorization.application, + mocks.server.plugins.security.authorization.actions, + ); }, }); diff --git a/x-pack/plugins/security/server/lib/validate_config.js b/x-pack/plugins/security/server/lib/validate_config.js index d1f80cb65b7ac9..49c9ba94ffd57f 100644 --- a/x-pack/plugins/security/server/lib/validate_config.js +++ b/x-pack/plugins/security/server/lib/validate_config.js @@ -6,10 +6,6 @@ const crypto = require('crypto'); -const isDefault = (config, key) => { - return config.getDefault(key) === config.get(key); -}; - export function validateConfig(config, log) { if (config.get('xpack.security.encryptionKey') == null) { log('Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + @@ -31,8 +27,4 @@ export function validateConfig(config, log) { } else { config.set('xpack.security.secureCookies', true); } - - if (!isDefault(config, 'kibana.index') && isDefault(config, 'xpack.security.rbac.application')) { - throw new Error('When setting kibana.index, xpack.security.rbac.application must be set as well.'); - } } diff --git a/x-pack/plugins/security/server/routes/api/v1/privileges.js b/x-pack/plugins/security/server/routes/api/v1/privileges.js index 375f6879972701..4bf1b2c5cc7a5e 100644 --- a/x-pack/plugins/security/server/routes/api/v1/privileges.js +++ b/x-pack/plugins/security/server/routes/api/v1/privileges.js @@ -7,9 +7,7 @@ import { buildPrivilegeMap } from '../../../lib/authorization'; export function initPrivilegesApi(server) { - const config = server.config(); const { authorization } = server.plugins.security; - const application = config.get('xpack.security.rbac.application'); const savedObjectTypes = server.savedObjects.types; server.route({ @@ -20,7 +18,7 @@ export function initPrivilegesApi(server) { // in Elasticsearch because our current thinking is that we'll associate additional structure/metadata // with our view of them to allow users to more efficiently edit privileges for roles, and serialize it // into a different structure for enforcement within Elasticsearch - const privileges = buildPrivilegeMap(savedObjectTypes, application, authorization.actions); + const privileges = buildPrivilegeMap(savedObjectTypes, authorization.application, authorization.actions); reply(Object.values(privileges)); } }); diff --git a/x-pack/test/rbac_api_integration/apis/privileges/index.js b/x-pack/test/rbac_api_integration/apis/privileges/index.js index bc5bc62f608384..74e30e5616c039 100644 --- a/x-pack/test/rbac_api_integration/apis/privileges/index.js +++ b/x-pack/test/rbac_api_integration/apis/privileges/index.js @@ -16,13 +16,13 @@ export default function ({ getService }) { .then(resp => { expect(resp.body).to.eql([ { - application: 'kibana', + application: 'kibana-.kibana', name: 'all', actions: ['version:7.0.0-alpha1', 'action:*'], metadata: {}, }, { - application: 'kibana', + application: 'kibana-.kibana', name: 'read', actions: [ 'version:7.0.0-alpha1', diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/index.js b/x-pack/test/rbac_api_integration/apis/saved_objects/index.js index 6336d4bcd47ecc..2c11949d8bb473 100644 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/index.js +++ b/x-pack/test/rbac_api_integration/apis/saved_objects/index.js @@ -6,6 +6,7 @@ import { AUTHENTICATION } from "./lib/authentication"; +const application = 'kibana-.kibana'; export default function ({ loadTestFile, getService }) { const es = getService('es'); @@ -42,7 +43,7 @@ export default function ({ loadTestFile, getService }) { index: [], applications: [ { - application: 'kibana', + application, privileges: [ 'all' ], resources: [ '*' ] } @@ -57,7 +58,7 @@ export default function ({ loadTestFile, getService }) { index: [], applications: [ { - application: 'kibana', + application, privileges: [ 'read' ], resources: [ '*' ] } From fb724d2ca09ddc8f22175e357a0ea227399df519 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 12 Jul 2018 13:35:20 -0400 Subject: [PATCH 078/183] Validate ES has_privileges response before trusting it (#20682) * validate elasticsearch has_privileges response before trusting it * address feedback --- .../check_privileges.test.js.snap | 8 + .../validate_es_response.test.js.snap | 43 +++ .../lib/authorization/check_privileges.js | 8 +- .../authorization/check_privileges.test.js | 74 +++- .../server/lib/authorization/privileges.js | 4 + .../lib/authorization/validate_es_response.js | 65 ++++ .../validate_es_response.test.js | 357 ++++++++++++++++++ 7 files changed, 556 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/security/server/lib/authorization/__snapshots__/validate_es_response.test.js.snap create mode 100644 x-pack/plugins/security/server/lib/authorization/validate_es_response.js create mode 100644 x-pack/plugins/security/server/lib/authorization/validate_es_response.test.js diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap index 4c0c88a8f8313c..7609d57b702faf 100644 --- a/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap @@ -1,5 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`with a malformed Elasticsearch response throws a validation error when an extra index privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "index" fails because [child "default-index" fails because ["oopsAnExtraPrivilege" is not allowed]]]`; + +exports[`with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because ["oops-an-unexpected-privilege" is not allowed]]]]`; + +exports[`with a malformed Elasticsearch response throws a validation error when index privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "index" fails because [child "default-index" fails because [child "read" fails because ["read" is required]]]]`; + +exports[`with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]`; + exports[`with index privileges throws error if missing version privilege and has login privilege 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; exports[`with no index privileges throws error if missing version privilege and has login privilege 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/validate_es_response.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/validate_es_response.test.js.snap new file mode 100644 index 00000000000000..5e1e26a9023ae7 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/validate_es_response.test.js.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`validateEsPrivilegeResponse fails validation when an action is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [child \\"action3\\" fails because [\\"action3\\" must be a boolean]]]]"`; + +exports[`validateEsPrivilegeResponse fails validation when an action is missing in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [child \\"action2\\" fails because [\\"action2\\" is required]]]]"`; + +exports[`validateEsPrivilegeResponse fails validation when an extra action is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"action4\\" is not allowed]]]"`; + +exports[`validateEsPrivilegeResponse fails validation when an extra application is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [\\"otherApplication\\" is not allowed]"`; + +exports[`validateEsPrivilegeResponse fails validation when an unexpected resource property is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"foo-resource\\" is required]]]"`; + +exports[`validateEsPrivilegeResponse fails validation when the "application" property is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [\\"application\\" is required]"`; + +exports[`validateEsPrivilegeResponse fails validation when the expected resource property is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"foo-resource\\" is required]]]"`; + +exports[`validateEsPrivilegeResponse fails validation when the requested application is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [\\"foo-application\\" is required]]"`; + +exports[`validateEsPrivilegeResponse fails validation when the resource propertry is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"foo-resource\\" must be an object]]]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the create index privilege is malformed 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"create\\" fails because [\\"create\\" must be a boolean]]]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the create index privilege is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"create\\" fails because [\\"create\\" is required]]]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the delete index privilege is malformed 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"delete\\" fails because [\\"delete\\" must be a boolean]]]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the delete index privilege is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"delete\\" fails because [\\"delete\\" is required]]]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the index privilege response contains an extra privilege 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [\\"foo-permission\\" is not allowed]]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the index privilege response returns an extra index 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [\\"anotherIndex\\" is not allowed]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the index property is missing 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [\\"index\\" is required]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the kibana index is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [\\".kibana\\" is required]]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the read index privilege is malformed 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"read\\" fails because [\\"read\\" must be a boolean]]]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the read index privilege is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"read\\" fails because [\\"read\\" is required]]]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the view_index_metadata index privilege is malformed 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"view_index_metadata\\" fails because [\\"view_index_metadata\\" must be a boolean]]]"`; + +exports[`validateEsPrivilegeResponse legacy should fail if the view_index_metadata index privilege is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"view_index_metadata\\" fails because [\\"view_index_metadata\\" is required]]]"`; diff --git a/x-pack/plugins/security/server/lib/authorization/check_privileges.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.js index 594966e5b3e4ef..2f5c58293d15f2 100644 --- a/x-pack/plugins/security/server/lib/authorization/check_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.js @@ -6,6 +6,8 @@ import { uniq } from 'lodash'; import { ALL_RESOURCE } from '../../../common/constants'; +import { buildLegacyIndexPrivileges } from './privileges'; +import { validateEsPrivilegeResponse } from './validate_es_response'; export const CHECK_PRIVILEGES_RESULT = { UNAUTHORIZED: Symbol('Unauthorized'), @@ -59,13 +61,15 @@ export function checkPrivilegesWithRequestFactory(shieldClient, config, actions, }], index: [{ names: [kibanaIndex], - privileges: ['create', 'delete', 'read', 'view_index_metadata'] + privileges: buildLegacyIndexPrivileges() }], } }); + validateEsPrivilegeResponse(hasPrivilegesResponse, application, allApplicationPrivileges, [ALL_RESOURCE], kibanaIndex); + const applicationPrivilegesResponse = hasPrivilegesResponse.application[application][ALL_RESOURCE]; - const indexPrivilegesResponse = hasPrivilegesResponse.index[kibanaIndex]; + const indexPrivilegesResponse = hasPrivilegesResponse.index[kibanaIndex]; if (hasIncompatibileVersion(applicationPrivilegesResponse)) { throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.'); diff --git a/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js index edf3b42d11c28f..a64110515e803c 100644 --- a/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js @@ -94,7 +94,7 @@ const checkPrivilegesTest = ( ]) }], index: [{ - names: [ defaultKibanaIndex ], + names: [defaultKibanaIndex], privileges: ['create', 'delete', 'read', 'view_index_metadata'] }], } @@ -328,3 +328,75 @@ describe('with no application privileges', () => { }); }); }); + +describe('with a malformed Elasticsearch response', () => { + const indexPrivilegesResponse = { + create: true, + delete: true, + read: true, + view_index_metadata: true, + }; + + checkPrivilegesTest('throws a validation error when an extra privilege is present in the response', { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get`, + ], + applicationPrivilegesResponse: { + [mockActions.version]: true, + [mockActions.login]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + ['oops-an-unexpected-privilege']: true, + }, + indexPrivilegesResponse, + expectErrorThrown: true, + }); + + checkPrivilegesTest('throws a validation error when privileges are missing in the response', { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get`, + ], + applicationPrivilegesResponse: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + }, + indexPrivilegesResponse, + expectErrorThrown: true, + }); + + checkPrivilegesTest('throws a validation error when an extra index privilege is present in the response', { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get`, + ], + applicationPrivilegesResponse: { + [mockActions.version]: true, + [mockActions.login]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + }, + indexPrivilegesResponse: { + ...indexPrivilegesResponse, + oopsAnExtraPrivilege: true, + }, + expectErrorThrown: true, + }); + + const missingIndexPrivileges = { + ...indexPrivilegesResponse + }; + delete missingIndexPrivileges.read; + + checkPrivilegesTest('throws a validation error when index privileges are missing in the response', { + username: 'foo-username', + privileges: [ + `action:saved_objects/${savedObjectTypes[0]}/get`, + ], + applicationPrivilegesResponse: { + [mockActions.version]: true, + [mockActions.login]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + }, + indexPrivilegesResponse: missingIndexPrivileges, + expectErrorThrown: true, + }); +}); diff --git a/x-pack/plugins/security/server/lib/authorization/privileges.js b/x-pack/plugins/security/server/lib/authorization/privileges.js index 9ded71662ed51c..6f64871ed75566 100644 --- a/x-pack/plugins/security/server/lib/authorization/privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/privileges.js @@ -28,3 +28,7 @@ export function buildPrivilegeMap(savedObjectTypes, application, actions) { } }; } + +export function buildLegacyIndexPrivileges() { + return ['create', 'delete', 'read', 'view_index_metadata']; +} diff --git a/x-pack/plugins/security/server/lib/authorization/validate_es_response.js b/x-pack/plugins/security/server/lib/authorization/validate_es_response.js new file mode 100644 index 00000000000000..34d618398bc3d3 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/validate_es_response.js @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { buildLegacyIndexPrivileges } from './privileges'; + +const legacyIndexPrivilegesSchema = Joi.object({ + ...buildLegacyIndexPrivileges().reduce((acc, privilege) => { + return { + ...acc, + [privilege]: Joi.bool().required() + }; + }, {}) +}).required(); + +export function validateEsPrivilegeResponse(response, application, actions, resources, kibanaIndex) { + const schema = buildValidationSchema(application, actions, resources, kibanaIndex); + const { error, value } = schema.validate(response); + + if (error) { + throw new Error(`Invalid response received from Elasticsearch has_privilege endpoint. ${error}`); + } + + return value; +} + +function buildActionsValidationSchema(actions) { + return Joi.object({ + ...actions.reduce((acc, action) => { + return { + ...acc, + [action]: Joi.bool().required() + }; + }, {}) + }).required(); +} + +function buildValidationSchema(application, actions, resources, kibanaIndex) { + + const actionValidationSchema = buildActionsValidationSchema(actions); + + const resourceValidationSchema = Joi.object({ + ...resources.reduce((acc, resource) => { + return { + ...acc, + [resource]: actionValidationSchema + }; + }, {}) + }).required(); + + return Joi.object({ + username: Joi.string().required(), + has_all_requested: Joi.bool(), + cluster: Joi.object(), + application: Joi.object({ + [application]: resourceValidationSchema, + }).required(), + index: Joi.object({ + [kibanaIndex]: legacyIndexPrivilegesSchema + }).required() + }).required(); +} diff --git a/x-pack/plugins/security/server/lib/authorization/validate_es_response.test.js b/x-pack/plugins/security/server/lib/authorization/validate_es_response.test.js new file mode 100644 index 00000000000000..f3dbad1b56ac95 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/validate_es_response.test.js @@ -0,0 +1,357 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { validateEsPrivilegeResponse } from "./validate_es_response"; +import { buildLegacyIndexPrivileges } from "./privileges"; + +const resource = 'foo-resource'; +const application = 'foo-application'; +const kibanaIndex = '.kibana'; + +const commonResponse = { + username: 'user', + has_all_requested: true, +}; + +describe('validateEsPrivilegeResponse', () => { + const legacyIndexResponse = { + [kibanaIndex]: { + 'create': true, + 'delete': true, + 'read': true, + 'view_index_metadata': true, + } + }; + + it('should validate a proper response', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true, + action2: true, + action3: true + } + } + }, + index: legacyIndexResponse + }; + + const result = validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex); + expect(result).toEqual(response); + }); + + it('fails validation when an action is missing in the response', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true, + action3: true + } + } + }, + index: legacyIndexResponse + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('fails validation when an extra action is present in the response', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true, + action2: true, + action3: true, + action4: true, + } + } + }, + index: legacyIndexResponse + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('fails validation when an action is malformed in the response', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true, + action2: true, + action3: 'not a boolean', + } + } + }, + index: legacyIndexResponse + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('fails validation when an extra application is present in the response', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true, + action2: true, + action3: true, + } + }, + otherApplication: { + [resource]: { + action1: true, + action2: true, + action3: true, + } + } + }, + index: legacyIndexResponse + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('fails validation when the requested application is missing from the response', () => { + const response = { + ...commonResponse, + application: {}, + index: legacyIndexResponse + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('fails validation when the "application" property is missing from the response', () => { + const response = { + ...commonResponse, + index: {} + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('fails validation when the expected resource property is missing from the response', () => { + const response = { + ...commonResponse, + application: { + [application]: {} + }, + index: legacyIndexResponse + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('fails validation when an unexpected resource property is present in the response', () => { + const response = { + ...commonResponse, + application: { + [application]: { + 'other-resource': { + action1: true, + action2: true, + action3: true, + } + } + }, + index: legacyIndexResponse + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('fails validation when the resource propertry is malformed in the response', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: 'not-an-object' + } + }, + index: legacyIndexResponse + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + describe('legacy', () => { + it('should validate a proper response', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true + } + } + }, + index: legacyIndexResponse + }; + + const result = validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex); + expect(result).toEqual(response); + }); + + it('should fail if the index property is missing', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true + } + } + } + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('should fail if the kibana index is missing from the response', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true + } + } + }, + index: {} + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('should fail if the index privilege response returns an extra index', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true + } + } + }, + index: { + ...legacyIndexResponse, + 'anotherIndex': { + foo: true + } + } + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + it('should fail if the index privilege response contains an extra privilege', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true + } + } + }, + index: { + [kibanaIndex]: { + ...legacyIndexResponse[kibanaIndex], + 'foo-permission': true + } + } + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + buildLegacyIndexPrivileges().forEach(privilege => { + test(`should fail if the ${privilege} index privilege is missing from the response`, () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true + } + } + }, + index: { + [kibanaIndex]: { + ...legacyIndexResponse[kibanaIndex] + } + } + }; + + delete response.index[kibanaIndex][privilege]; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + + test(`should fail if the ${privilege} index privilege is malformed`, () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource]: { + action1: true + } + } + }, + index: { + [kibanaIndex]: { + ...legacyIndexResponse[kibanaIndex] + } + } + }; + + response.index[kibanaIndex][privilege] = 'not a boolean'; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) + ).toThrowErrorMatchingSnapshot(); + }); + }); + }); +}); From c65908b38cdebdcdfb06e4e30eb228fd38f0fcef Mon Sep 17 00:00:00 2001 From: kobelb Date: Thu, 12 Jul 2018 15:21:30 -0400 Subject: [PATCH 079/183] Removing unused setting --- x-pack/plugins/security/index.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 8e0c5ad68c64c7..9cf76e3b354e46 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -42,12 +42,6 @@ export const security = (kibana) => new kibana.Plugin({ hostname: Joi.string().hostname(), port: Joi.number().integer().min(0).max(65535) }).default(), - rbac: Joi.object({ - application: Joi.string().default('kibana').regex( - /[a-zA-Z0-9-_]+/, - `may contain alphanumeric characters (a-z, A-Z, 0-9), underscores and hyphens` - ), - }).default(), audit: Joi.object({ enabled: Joi.boolean().default(false) }).default(), From a597976cd1f9115248a4ddc0bed11645b8cd1906 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Fri, 13 Jul 2018 12:38:37 -0400 Subject: [PATCH 080/183] Public Role APIs (#20732) * Beginning to work on external role management APIs * Refactoring GET tests and adding more permutations * Adding test for excluding other resources * Adding get role tests * Splitting out the endpoints, or else it's gonna get overwhelming * Splitting out the post and delete actions * Beginning to work on POST and the tests * Posting the updated role * Adding update tests * Modifying the UI to use the new public APIs * Removing internal roles API * Moving the rbac api integration setup tests to use the public role apis * Testing field_security and query * Adding create role tests * We can't update the transient_metadata... * Removing debugger * Update and delete tests * Returning a 204 when POSTing a Role. * Switching POST to PUT and roles to role * We don't need the rbacApplication client-side anymore * Adding delete route tests * Using not found instead of not acceptable, as that's more likely * Only allowing us to PUT known Kibana privileges * Removing transient_metadata * Removing one letter variable names * Using PUT instead of POST when saving roles * Fixing broken tests --- x-pack/plugins/security/index.js | 6 +- .../security/public/services/shield_role.js | 11 +- .../public/views/management/edit_role.html | 14 +- .../public/views/management/edit_role.js | 80 +-- .../server/routes/api/public/roles/delete.js | 33 + .../routes/api/public/roles/delete.test.js | 125 ++++ .../server/routes/api/public/roles/get.js | 78 +++ .../routes/api/public/roles/get.test.js | 577 ++++++++++++++++++ .../server/routes/api/public/roles/index.js | 25 + .../server/routes/api/public/roles/put.js | 110 ++++ .../routes/api/public/roles/put.test.js | 503 +++++++++++++++ .../security/server/routes/api/v1/roles.js | 84 --- .../api_integration/apis/security/index.js | 1 + .../api_integration/apis/security/roles.js | 221 +++++++ x-pack/test/api_integration/config.js | 3 +- x-pack/test/api_integration/services/es.js | 20 + x-pack/test/api_integration/services/index.js | 1 + .../apis/saved_objects/index.js | 74 +-- 18 files changed, 1775 insertions(+), 191 deletions(-) create mode 100644 x-pack/plugins/security/server/routes/api/public/roles/delete.js create mode 100644 x-pack/plugins/security/server/routes/api/public/roles/delete.test.js create mode 100644 x-pack/plugins/security/server/routes/api/public/roles/get.js create mode 100644 x-pack/plugins/security/server/routes/api/public/roles/get.test.js create mode 100644 x-pack/plugins/security/server/routes/api/public/roles/index.js create mode 100644 x-pack/plugins/security/server/routes/api/public/roles/put.js create mode 100644 x-pack/plugins/security/server/routes/api/public/roles/put.test.js delete mode 100644 x-pack/plugins/security/server/routes/api/v1/roles.js create mode 100644 x-pack/test/api_integration/apis/security/roles.js create mode 100644 x-pack/test/api_integration/services/es.js diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 9cf76e3b354e46..f0c76daf393a3e 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -8,7 +8,7 @@ import { resolve } from 'path'; import { getUserProvider } from './server/lib/get_user'; import { initAuthenticateApi } from './server/routes/api/v1/authenticate'; import { initUsersApi } from './server/routes/api/v1/users'; -import { initRolesApi } from './server/routes/api/v1/roles'; +import { initPublicRolesApi } from './server/routes/api/public/roles'; import { initIndicesApi } from './server/routes/api/v1/indices'; import { initLoginView } from './server/routes/views/login'; import { initLogoutView } from './server/routes/views/logout'; @@ -70,11 +70,9 @@ export const security = (kibana) => new kibana.Plugin({ injectDefaultVars: function (server) { const config = server.config(); - const { authorization } = server.plugins.security; return { secureCookies: config.get('xpack.security.secureCookies'), sessionTimeout: config.get('xpack.security.sessionTimeout'), - rbacApplication: authorization.application, }; } }, @@ -146,7 +144,7 @@ export const security = (kibana) => new kibana.Plugin({ await initAuthenticator(server); initAuthenticateApi(server); initUsersApi(server); - initRolesApi(server); + initPublicRolesApi(server); initIndicesApi(server); initPrivilegesApi(server); initLoginView(server, xpackMainPlugin); diff --git a/x-pack/plugins/security/public/services/shield_role.js b/x-pack/plugins/security/public/services/shield_role.js index 1954415f0384bd..3ee73dbad57dbb 100644 --- a/x-pack/plugins/security/public/services/shield_role.js +++ b/x-pack/plugins/security/public/services/shield_role.js @@ -5,11 +5,20 @@ */ import 'angular-resource'; +import { omit } from 'lodash'; +import angular from 'angular'; import { uiModules } from 'ui/modules'; const module = uiModules.get('security', ['ngResource']); module.service('ShieldRole', ($resource, chrome) => { - return $resource(chrome.addBasePath('/api/security/v1/roles/:name'), { + return $resource(chrome.addBasePath('/api/security/role/:name'), { name: '@name' + }, { + save: { + method: 'PUT', + transformRequest(data) { + return angular.toJson(omit(data, 'name', 'transient_metadata', '_unrecognized_applications')); + } + } }); }); diff --git a/x-pack/plugins/security/public/views/management/edit_role.html b/x-pack/plugins/security/public/views/management/edit_role.html index 3b5c7a13412a74..a2f51552403d4b 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.html +++ b/x-pack/plugins/security/public/views/management/edit_role.html @@ -101,8 +101,8 @@

{{privilege}} @@ -116,12 +116,12 @@

Kibana Privileges -
+