diff --git a/docs/development/core/server/kibana-plugin-server.md b/docs/development/core/server/kibana-plugin-server.md index 3c0699485d98f..9166aec9a6f91 100644 --- a/docs/development/core/server/kibana-plugin-server.md +++ b/docs/development/core/server/kibana-plugin-server.md @@ -83,4 +83,5 @@ The plugin integrates with the core system via lifecycle events: `setup` | [RouteMethod](./kibana-plugin-server.routemethod.md) | The set of common HTTP methods supported by Kibana routing. | | [SavedObjectsClientContract](./kibana-plugin-server.savedobjectsclientcontract.md) | \#\# SavedObjectsClient errorsSince the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to to application code. Ideally, all errors will be either:1. Caused by bad implementation (ie. undefined is not a function) and as such unpredictable 2. An error that has been classified and decorated appropriately by the decorators in [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md)Type 1 errors are inevitable, but since all expected/handle-able errors should be Type 2 the isXYZError() helpers exposed at SavedObjectsErrorHelpers should be used to understand and manage error responses from the SavedObjectsClient.Type 2 errors are decorated versions of the source error, so if the elasticsearch client threw an error it will be decorated based on its type. That means that rather than looking for error.body.error.type or doing substring checks on error.body.error.reason, just use the helpers to understand the meaning of the error:\`\`\`js if (SavedObjectsErrorHelpers.isNotFoundError(error)) { // handle 404 }if (SavedObjectsErrorHelpers.isNotAuthorizedError(error)) { // 401 handling should be automatic, but in case you wanted to know }// always rethrow the error unless you handle it throw error; \`\`\`\#\#\# 404s from missing indexFrom the perspective of application code and APIs the SavedObjectsClient is a black box that persists objects. One of the internal details that users have no control over is that we use an elasticsearch index for persistance and that index might be missing.At the time of writing we are in the process of transitioning away from the operating assumption that the SavedObjects index is always available. Part of this transition is handling errors resulting from an index missing. These used to trigger a 500 error in most cases, and in others cause 404s with different error messages.From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The object the request/call was targeting could not be found. This is why \#14141 takes special care to ensure that 404 errors are generic and don't distinguish between index missing or document missing.\#\#\# 503s from missing indexUnlike all other methods, create requests are supposed to succeed even when the Kibana index does not exist because it will be automatically created by elasticsearch. When that is not the case it is because Elasticsearch's action.auto_create_index setting prevents it from being created automatically so we throw a special 503 with the intention of informing the user that their Elasticsearch settings need to be updated.See [SavedObjectsErrorHelpers](./kibana-plugin-server.savedobjectserrorhelpers.md) | | [SavedObjectsClientWrapperFactory](./kibana-plugin-server.savedobjectsclientwrapperfactory.md) | | +| [SavedObjectsNamespace](./kibana-plugin-server.savedobjectsnamespace.md) | Saved Objects Namespace. | diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsbaseoptions.md b/docs/development/core/server/kibana-plugin-server.savedobjectsbaseoptions.md index 6eace924490cc..4ea62f9800978 100644 --- a/docs/development/core/server/kibana-plugin-server.savedobjectsbaseoptions.md +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsbaseoptions.md @@ -15,5 +15,5 @@ export interface SavedObjectsBaseOptions | Property | Type | Description | | --- | --- | --- | -| [namespace](./kibana-plugin-server.savedobjectsbaseoptions.namespace.md) | string | Specify the namespace for this operation | +| [namespace](./kibana-plugin-server.savedobjectsbaseoptions.namespace.md) | SavedObjectsNamespace | Specify the namespace for this operation | diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsbaseoptions.namespace.md b/docs/development/core/server/kibana-plugin-server.savedobjectsbaseoptions.namespace.md index 6e921dc8ab60e..bfb5049110bbf 100644 --- a/docs/development/core/server/kibana-plugin-server.savedobjectsbaseoptions.namespace.md +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsbaseoptions.namespace.md @@ -9,5 +9,5 @@ Specify the namespace for this operation Signature: ```typescript -namespace?: string; +namespace?: SavedObjectsNamespace; ``` diff --git a/docs/development/core/server/kibana-plugin-server.savedobjectsnamespace.md b/docs/development/core/server/kibana-plugin-server.savedobjectsnamespace.md new file mode 100644 index 0000000000000..baeb47b53f1f2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.savedobjectsnamespace.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [SavedObjectsNamespace](./kibana-plugin-server.savedobjectsnamespace.md) + +## SavedObjectsNamespace type + +Saved Objects Namespace. + +Signature: + +```typescript +export declare type SavedObjectsNamespace = string | undefined; +``` diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 51727b6e02cf1..dbf99431902cc 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -97,6 +97,7 @@ export { SavedObjectsFindOptions, SavedObjectsFindResponse, SavedObjectsMigrationVersion, + SavedObjectsNamespace, SavedObjectsService, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, diff --git a/src/core/server/saved_objects/serialization/index.ts b/src/core/server/saved_objects/serialization/index.ts index 86a448ba8a5be..0e755d1aba55b 100644 --- a/src/core/server/saved_objects/serialization/index.ts +++ b/src/core/server/saved_objects/serialization/index.ts @@ -25,6 +25,7 @@ /* eslint-disable @typescript-eslint/camelcase */ import uuid from 'uuid'; +import { SavedObjectsNamespace } from 'src/core/server/saved_objects'; import { SavedObjectsSchema } from '../schema'; import { decodeVersion, encodeVersion } from '../version'; import { @@ -53,7 +54,7 @@ interface SavedObjectDoc { attributes: object; id?: string; // NOTE: SavedObjectDoc is used for uncreated objects where `id` is optional type: string; - namespace?: string; + namespace?: SavedObjectsNamespace; migrationVersion?: SavedObjectsMigrationVersion; version?: string; updated_at?: string; @@ -167,18 +168,18 @@ export class SavedObjectsSerializer { * @param {string} type - The saved object type * @param {string} id - The id of the saved object */ - public generateRawId(namespace: string | undefined, type: string, id?: string) { + public generateRawId(namespace: SavedObjectsNamespace, type: string, id?: string) { const namespacePrefix = - namespace && !this.schema.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + namespace && !this.schema.isNamespaceAgnostic(type) ? `${String(namespace)}:` : ''; return `${namespacePrefix}${type}:${id || uuid.v1()}`; } - private trimIdPrefix(namespace: string | undefined, type: string, id: string) { + private trimIdPrefix(namespace: SavedObjectsNamespace, type: string, id: string) { assertNonEmptyString(id, 'document id'); assertNonEmptyString(type, 'saved object type'); const namespacePrefix = - namespace && !this.schema.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + namespace && !this.schema.isNamespaceAgnostic(type) ? `${String(namespace)}:` : ''; const prefix = `${namespacePrefix}${type}:`; if (!id.startsWith(prefix)) { diff --git a/src/core/server/saved_objects/service/index.ts b/src/core/server/saved_objects/service/index.ts index 697e1d2d41471..b43c3ff9993ed 100644 --- a/src/core/server/saved_objects/service/index.ts +++ b/src/core/server/saved_objects/service/index.ts @@ -39,6 +39,7 @@ export { ScopedSavedObjectsClientProvider, SavedObjectsClientWrapperFactory, SavedObjectsClientWrapperOptions, + SavedObjectsNamespace, SavedObjectsErrorHelpers, } from './lib'; diff --git a/src/core/server/saved_objects/service/lib/__snapshots__/repository.test.js.snap b/src/core/server/saved_objects/service/lib/__snapshots__/repository.test.js.snap index 609906c97d599..a4b0deed4e191 100644 --- a/src/core/server/saved_objects/service/lib/__snapshots__/repository.test.js.snap +++ b/src/core/server/saved_objects/service/lib/__snapshots__/repository.test.js.snap @@ -1,5 +1,23 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SavedObjectsRepository #deleteByNamespace requires namespace to be a string 1`] = `"namespace is required, and must be a string"`; +exports[`SavedObjectsRepository #bulkCreate doesn't support namespaces which aren't undefined or strings 1`] = `"Expected namespace to be either a string or undefined. Found symbol (Symbol(foo-namespace))"`; -exports[`SavedObjectsRepository #deleteByNamespace requires namespace to be defined 1`] = `"namespace is required, and must be a string"`; +exports[`SavedObjectsRepository #bulkGet doesn't support namespaces which aren't undefined or strings 1`] = `"Expected namespace to be either a string or undefined. Found symbol (Symbol(foo))"`; + +exports[`SavedObjectsRepository #create doesn't support namespaces which aren't undefined or strings 1`] = `"Expected namespace to be either a string or undefined. Found symbol (Symbol(foo-namespace))"`; + +exports[`SavedObjectsRepository #delete doesn't support namespaces which aren't undefined or strings 1`] = `"Expected namespace to be either a string or undefined. Found symbol (Symbol(foo-namespace))"`; + +exports[`SavedObjectsRepository #deleteByNamespace doesn't support Symbol namespaces 1`] = `"Expected namespace to be either a string or undefined. Found symbol (Symbol(namespace-1))"`; + +exports[`SavedObjectsRepository #deleteByNamespace requires namespace to be a string 1`] = `"Expected namespace to be either a string or undefined. Found object (namespace-1,namespace-2)"`; + +exports[`SavedObjectsRepository #deleteByNamespace requires namespace to be defined 1`] = `"namespace is required"`; + +exports[`SavedObjectsRepository #find doesn't support namespaces which aren't undefined or strings 1`] = `"Expected namespace to be either a string or undefined. Found symbol (Symbol(foo))"`; + +exports[`SavedObjectsRepository #get doesn't support Symbol namespaces 1`] = `"Expected namespace to be either a string or undefined. Found symbol (Symbol(foo-namespace))"`; + +exports[`SavedObjectsRepository #incrementCounter doesn't supports namespaces which aren't undefined or strings 1`] = `"Expected namespace to be either a string or undefined. Found symbol (Symbol(foo-namespace))"`; + +exports[`SavedObjectsRepository #update doesn't support namespaces which aren't undefined or strings 1`] = `"Expected namespace to be either a string or undefined. Found symbol (Symbol(foo-namespace))"`; diff --git a/src/core/server/saved_objects/service/lib/index.ts b/src/core/server/saved_objects/service/lib/index.ts index 19fdc3d75f603..78ff8728d1310 100644 --- a/src/core/server/saved_objects/service/lib/index.ts +++ b/src/core/server/saved_objects/service/lib/index.ts @@ -24,4 +24,8 @@ export { ScopedSavedObjectsClientProvider, } from './scoped_client_provider'; +export { SavedObjectsNamespace } from './namespace'; + +import * as errors from './errors'; +export { errors }; export { SavedObjectsErrorHelpers } from './errors'; diff --git a/src/core/server/saved_objects/service/lib/namespace.ts b/src/core/server/saved_objects/service/lib/namespace.ts new file mode 100644 index 0000000000000..7de9bbad6edc2 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/namespace.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Saved Objects Namespace. + * @public + */ +export type SavedObjectsNamespace = string | undefined; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 5a2e6a617fbb5..9fa3e0ae41f98 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -548,6 +548,19 @@ describe('SavedObjectsRepository', () => { ); expect(onBeforeWrite).toHaveBeenCalledTimes(1); }); + + it(`doesn't support namespaces which aren't undefined or strings`, async () => { + expect(savedObjectsRepository.create( + 'index-pattern', + { + title: 'Logstash', + }, + { + id: 'foo-id', + namespace: Symbol('foo-namespace'), + } + )).rejects.toThrowErrorMatchingSnapshot(); + }); }); describe('#bulkCreate', () => { @@ -993,6 +1006,15 @@ describe('SavedObjectsRepository', () => { expect(onBeforeWrite).toHaveBeenCalledTimes(1); }); + it(`doesn't support namespaces which aren't undefined or strings`, async () => { + expect(savedObjectsRepository.bulkCreate( + [{ type: 'globaltype', id: 'one', attributes: { title: 'Test One' } }], + { + namespace: Symbol('foo-namespace'), + } + )).rejects.toThrowErrorMatchingSnapshot(); + }); + it('should return objects in the same order regardless of type', () => {}); }); @@ -1071,6 +1093,12 @@ describe('SavedObjectsRepository', () => { expect(onBeforeWrite).toHaveBeenCalledTimes(1); }); + + it(`doesn't support namespaces which aren't undefined or strings`, async () => { + expect(savedObjectsRepository.delete('globaltype', 'logstash-*', { + namespace: Symbol('foo-namespace'), + })).rejects.toThrowErrorMatchingSnapshot(); + }); }); describe('#deleteByNamespace', () => { @@ -1090,6 +1118,15 @@ describe('SavedObjectsRepository', () => { expect(onBeforeWrite).not.toHaveBeenCalled(); }); + it(`doesn't support Symbol namespaces`, async () => { + callAdminCluster.mockReturnValue(deleteByQueryResults); + expect( + savedObjectsRepository.deleteByNamespace(Symbol('namespace-1')) + ).rejects.toThrowErrorMatchingSnapshot(); + expect(callAdminCluster).not.toHaveBeenCalled(); + expect(onBeforeWrite).not.toHaveBeenCalled(); + }); + it('constructs a deleteByQuery call using all types that are namespace aware', async () => { callAdminCluster.mockReturnValue(deleteByQueryResults); const result = await savedObjectsRepository.deleteByNamespace('my-namespace'); @@ -1283,6 +1320,11 @@ describe('SavedObjectsRepository', () => { expect(callAdminCluster).toHaveBeenCalledTimes(1); expect(callAdminCluster.mock.calls[0][1]).toHaveProperty('rest_total_hits_as_int', true); }); + + it(`doesn't support namespaces which aren't undefined or strings`, async () => { + expect(savedObjectsRepository.find({ type: 'foo', namespace: Symbol('foo') })) + .rejects.toThrowErrorMatchingSnapshot(); + }); }); describe('#get', () => { @@ -1404,6 +1446,12 @@ describe('SavedObjectsRepository', () => { }) ); }); + + it(`doesn't support Symbol namespaces`, async () => { + expect(savedObjectsRepository.get('globaltype', 'logstash-*', { + namespace: Symbol('foo-namespace'), + })).rejects.toThrowErrorMatchingSnapshot(); + }); }); describe('#bulkGet', () => { @@ -1645,6 +1693,17 @@ describe('SavedObjectsRepository', () => { }, ]); }); + + it(`doesn't support namespaces which aren't undefined or strings`, async () => { + expect(savedObjectsRepository.bulkGet([ + { id: 'one', type: 'config' }, + { id: 'two', type: 'invalidtype' }, + { id: 'three', type: 'config' }, + { id: 'four', type: 'invalidtype' }, + { id: 'five', type: 'config' }, + ], { namespace: Symbol('foo') })) + .rejects.toThrowErrorMatchingSnapshot(); + }); }); describe('#update', () => { @@ -1857,6 +1916,26 @@ describe('SavedObjectsRepository', () => { expect(onBeforeWrite).toHaveBeenCalledTimes(1); }); + + it(`doesn't support namespaces which aren't undefined or strings`, async () => { + expect(savedObjectsRepository.update( + 'index-pattern', + 'foo', + { + name: 'bar', + }, + { + namespace: Symbol('foo-namespace'), + references: [ + { + name: 'ref_0', + type: 'test', + id: '1', + }, + ], + } + )).rejects.toThrowErrorMatchingSnapshot(); + }); }); describe('#incrementCounter', () => { @@ -2053,6 +2132,14 @@ describe('SavedObjectsRepository', () => { ) ).rejects.toEqual(new Error('"counterFieldName" argument must be a string')); }); + + it(`doesn't supports namespaces which aren't undefined or strings`, async () => { + expect( + savedObjectsRepository.incrementCounter('globaltype', 'foo', 'counter', { + namespace: Symbol('foo-namespace'), + }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); }); describe('onBeforeWrite', () => { diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index eb41df3a19d2d..54bbb71292e7c 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -145,6 +145,8 @@ export class SavedObjectsRepository { ): Promise> { const { id, migrationVersion, overwrite, namespace, references } = options; + this.assertValidNamespace(namespace); + if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } @@ -200,6 +202,9 @@ export class SavedObjectsRepository { options: SavedObjectsCreateOptions = {} ): Promise> { const { namespace, overwrite = false } = options; + + this.assertValidNamespace(namespace); + const time = this._getCurrentTime(); const bulkCreateParams: object[] = []; @@ -314,6 +319,8 @@ export class SavedObjectsRepository { const { namespace } = options; + this.assertValidNamespace(namespace); + const response = await this._writeToCluster('delete', { id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), @@ -345,10 +352,12 @@ export class SavedObjectsRepository { * @returns {promise} - { took, timed_out, total, deleted, batches, version_conflicts, noops, retries, failures } */ async deleteByNamespace(namespace: string): Promise { - if (!namespace || typeof namespace !== 'string') { - throw new TypeError(`namespace is required, and must be a string`); + if (!namespace) { + throw new TypeError(`namespace is required`); } + this.assertValidNamespace(namespace); + const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); const typesToDelete = allTypes.filter(type => !this._schema.isNamespaceAgnostic(type)); @@ -421,6 +430,8 @@ export class SavedObjectsRepository { throw new TypeError('options.fields must be an array'); } + this.assertValidNamespace(namespace); + const esOptions = { index: this.getIndicesForTypes(allowedTypes), size: perPage, @@ -484,6 +495,8 @@ export class SavedObjectsRepository { ): Promise> { const { namespace } = options; + this.assertValidNamespace(namespace); + if (objects.length === 0) { return { saved_objects: [] }; } @@ -560,6 +573,8 @@ export class SavedObjectsRepository { const { namespace } = options; + this.assertValidNamespace(namespace); + const response = await this._callCluster('get', { id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), @@ -609,6 +624,8 @@ export class SavedObjectsRepository { const { version, namespace, references = [] } = options; + this.assertValidNamespace(namespace); + const time = this._getCurrentTime(); const response = await this._writeToCluster('update', { id: this._serializer.generateRawId(namespace, type, id), @@ -668,6 +685,8 @@ export class SavedObjectsRepository { const { migrationVersion, namespace } = options; + this.assertValidNamespace(namespace); + const time = this._getCurrentTime(); const migrated = this._migrator.migrateDocument({ @@ -768,4 +787,15 @@ export class SavedObjectsRepository { const savedObject = this._serializer.rawToSavedObject(raw); return omit(savedObject, 'namespace'); } + + private assertValidNamespace(namespace: unknown) { + const typeofNamespace = typeof namespace; + if (typeofNamespace !== 'undefined' && typeofNamespace !== 'string') { + throw new TypeError( + `Expected namespace to be either a string or undefined. Found ${typeofNamespace} (${String( + namespace + )})` + ); + } + } } diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 9c145258a755f..4d9e5108a917a 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -17,6 +17,7 @@ * under the License. */ +import { SavedObjectsNamespace } from 'src/core/server/saved_objects'; import { getRootPropertiesObjects, IndexMapping } from '../../../mappings'; import { SavedObjectsSchema } from '../../../schema'; @@ -59,7 +60,11 @@ function getFieldsForTypes(types: string[], searchFields?: string[]) { * Gets the clause that will filter for the type in the namespace. * Some types are namespace agnostic, so they must be treated differently. */ -function getClauseForType(schema: SavedObjectsSchema, namespace: string | undefined, type: string) { +function getClauseForType( + schema: SavedObjectsSchema, + namespace: SavedObjectsNamespace, + type: string +) { if (namespace && !schema.isNamespaceAgnostic(type)) { return { bool: { @@ -82,7 +87,7 @@ function getClauseForType(schema: SavedObjectsSchema, namespace: string | undefi export function getQueryParams( mappings: IndexMapping, schema: SavedObjectsSchema, - namespace?: string, + namespace?: SavedObjectsNamespace, type?: string | string[], search?: string, searchFields?: string[], diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 1c2c87bca6ea7..b91965d6ce63a 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -19,6 +19,7 @@ import Boom from 'boom'; +import { SavedObjectsNamespace } from 'src/core/server/saved_objects'; import { IndexMapping } from '../../../mappings'; import { SavedObjectsSchema } from '../../../schema'; import { getQueryParams } from './query_params'; @@ -31,7 +32,7 @@ interface GetSearchDslOptions { searchFields?: string[]; sortField?: string; sortOrder?: string; - namespace?: string; + namespace?: SavedObjectsNamespace; hasReference?: { type: string; id: string; diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index adc25a6d045e9..4d624b919c27f 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ - +import { SavedObjectsNamespace } from 'src/core/server'; import { SavedObjectsRepository } from './lib'; import { SavedObjectsErrorHelpers } from './lib/errors'; @@ -29,7 +29,7 @@ type Omit = Pick>; */ export interface SavedObjectsBaseOptions { /** Specify the namespace for this operation */ - namespace?: string; + namespace?: SavedObjectsNamespace; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 7aa793764cabc..44e18e721979d 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -14,6 +14,8 @@ import { Observable } from 'rxjs'; import { Request } from 'hapi'; import { ResponseObject } from 'hapi'; import { ResponseToolkit } from 'hapi'; +import { SavedObjectsNamespace as SavedObjectsNamespace_2 } from 'src/core/server'; +import { SavedObjectsNamespace as SavedObjectsNamespace_3 } from 'src/core/server/saved_objects'; import { Schema } from '@kbn/config-schema'; import { Server } from 'hapi'; import { Type } from '@kbn/config-schema'; @@ -437,7 +439,7 @@ export interface SavedObjectReference { // @public (undocumented) export interface SavedObjectsBaseOptions { - namespace?: string; + namespace?: SavedObjectsNamespace_2; } // @public (undocumented) @@ -616,6 +618,9 @@ export interface SavedObjectsMigrationVersion { [pluginName: string]: string; } +// @public +export type SavedObjectsNamespace = string | undefined; + // @public (undocumented) export interface SavedObjectsService { // Warning: (ae-forgotten-export) The symbol "ScopedSavedObjectsClientProvider" needs to be exported by the entry point index.d.ts diff --git a/x-pack/legacy/plugins/encrypted_saved_objects/server/lib/encrypted_saved_objects_service.ts b/x-pack/legacy/plugins/encrypted_saved_objects/server/lib/encrypted_saved_objects_service.ts index 7c3299b25517b..e0d8d87bee73e 100644 --- a/x-pack/legacy/plugins/encrypted_saved_objects/server/lib/encrypted_saved_objects_service.ts +++ b/x-pack/legacy/plugins/encrypted_saved_objects/server/lib/encrypted_saved_objects_service.ts @@ -9,6 +9,7 @@ import nodeCrypto from '@elastic/node-crypto'; import stringify from 'json-stable-stringify'; import typeDetect from 'type-detect'; import { Server } from 'kibana'; +import { SavedObjectsNamespace } from 'src/core/server'; import { EncryptedSavedObjectsAuditLogger } from './encrypted_saved_objects_audit_logger'; import { EncryptionError } from './encryption_error'; @@ -28,7 +29,7 @@ export interface EncryptedSavedObjectTypeRegistration { export interface SavedObjectDescriptor { readonly id: string; readonly type: string; - readonly namespace?: string; + readonly namespace?: SavedObjectsNamespace; } /** @@ -38,7 +39,7 @@ export interface SavedObjectDescriptor { */ export function descriptorToArray(descriptor: SavedObjectDescriptor) { return descriptor.namespace - ? [descriptor.namespace, descriptor.type, descriptor.id] + ? [String(descriptor.namespace), descriptor.type, descriptor.id] : [descriptor.type, descriptor.id]; } diff --git a/x-pack/legacy/plugins/encrypted_saved_objects/server/plugin.ts b/x-pack/legacy/plugins/encrypted_saved_objects/server/plugin.ts index 02b20798afc59..e632c6aca76f4 100644 --- a/x-pack/legacy/plugins/encrypted_saved_objects/server/plugin.ts +++ b/x-pack/legacy/plugins/encrypted_saved_objects/server/plugin.ts @@ -6,7 +6,7 @@ import crypto from 'crypto'; import { Legacy, Server } from 'kibana'; -import { SavedObjectsRepository } from 'src/core/server/saved_objects/service'; +import { SavedObjectsClientContract, SavedObjectsService } from 'src/core/server'; import { SavedObjectsBaseOptions, SavedObject, SavedObjectAttributes } from 'src/core/server'; import { EncryptedSavedObjectsService, @@ -22,7 +22,7 @@ export const CONFIG_PREFIX = `xpack.${PLUGIN_ID}`; interface CoreSetup { config: { encryptionKey?: string }; elasticsearch: Legacy.Plugins.elasticsearch.Plugin; - savedObjects: Legacy.SavedObjectsService; + savedObjects: SavedObjectsService; } interface PluginsSetup { @@ -63,7 +63,7 @@ export class Plugin { ({ client: baseClient }) => new EncryptedSavedObjectsClientWrapper({ baseClient, service }) ); - const internalRepository: SavedObjectsRepository = core.savedObjects.getSavedObjectsRepository( + const internalRepository: SavedObjectsClientContract = core.savedObjects.getSavedObjectsRepository( core.elasticsearch.getCluster('admin').callWithInternalUser ); @@ -76,11 +76,20 @@ export class Plugin { id: string, options?: SavedObjectsBaseOptions ): Promise> => { - const savedObject = await internalRepository.get(type, id, options); + if (options && typeof options.namespace === 'symbol') { + throw new TypeError( + 'symbols are unsupported namespace values within the encrypted saved objects plugin' + ); + } + const namespace: string | undefined = options + ? (options.namespace as string | undefined) + : undefined; + + const savedObject = await internalRepository.get(type, id, { ...options, namespace }); return { ...savedObject, attributes: await service.decryptAttributes( - { type, id, namespace: options && options.namespace }, + { type, id, namespace }, savedObject.attributes ), }; diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index 250c8729b5466..6ad916c08404d 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -27,10 +27,11 @@ import { initAPIAuthorization, initAppAuthorization, registerPrivilegesWithCluster, - validateFeaturePrivileges + validateFeaturePrivileges, + createSecureSavedObjectsWrapperFactory, + SECURE_SOC_WRAPPER_PRIORITY, } from './server/lib/authorization'; import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; -import { SecureSavedObjectsClientWrapper } from './server/lib/saved_objects_client/secure_saved_objects_client_wrapper'; import { deepFreeze } from './server/lib/deep_freeze'; import { createOptionalPlugin } from '../../server/lib/optional_plugin'; @@ -186,21 +187,12 @@ export const security = (kibana) => new kibana.Plugin({ return new savedObjects.SavedObjectsClient(callWithRequestRepository); }); - savedObjects.addScopedSavedObjectsClientWrapperFactory(Number.MIN_SAFE_INTEGER, ({ client, request }) => { - if (authorization.mode.useRbacForRequest(request)) { - return new SecureSavedObjectsClientWrapper({ - actions: authorization.actions, - auditLogger, - baseClient: client, - checkPrivilegesDynamicallyWithRequest: authorization.checkPrivilegesDynamicallyWithRequest, - errors: savedObjects.SavedObjectsClient.errors, - request, - savedObjectTypes: savedObjects.types, - }); - } - - return client; - }); + savedObjects.addScopedSavedObjectsClientWrapperFactory(SECURE_SOC_WRAPPER_PRIORITY, createSecureSavedObjectsWrapperFactory({ + authorization, + spaces, + savedObjects, + auditLogger + })); getUserProvider(server); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/index.ts b/x-pack/legacy/plugins/security/server/lib/authorization/index.ts index 32c05dc8a5ebc..bde90f8891dbc 100644 --- a/x-pack/legacy/plugins/security/server/lib/authorization/index.ts +++ b/x-pack/legacy/plugins/security/server/lib/authorization/index.ts @@ -14,3 +14,7 @@ export { PrivilegeSerializer } from './privilege_serializer'; export { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; export { ResourceSerializer } from './resource_serializer'; export { validateFeaturePrivileges } from './validate_feature_privileges'; +export { + createSecureSavedObjectsWrapperFactory, + SECURE_SOC_WRAPPER_PRIORITY, +} from './saved_objects'; diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/ensure_saved_objects_privileges.test.ts b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/ensure_saved_objects_privileges.test.ts new file mode 100644 index 0000000000000..117724726d6c3 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/ensure_saved_objects_privileges.test.ts @@ -0,0 +1,431 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Actions } from '..'; +import { ensureSavedObjectsPrivilegesFactory } from './ensure_saved_objects_privileges'; +import { DEFAULT_SPACE_ID } from '../../../../../spaces/common/constants'; + +const createMockErrors = () => { + const forbiddenError = new Error('Mock ForbiddenError'); + const generalError = new Error('Mock GeneralError'); + + return { + forbiddenError, + decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), + generalError, + decorateGeneralError: jest.fn().mockReturnValue(generalError), + } as any; +}; + +const createMockAuditLogger = () => { + return { + savedObjectsAuthorizationFailure: jest.fn(), + savedObjectsAuthorizationSuccess: jest.fn(), + }; +}; + +const createMockActions = () => { + return { + savedObject: { + get(type: string, action: string) { + return `mock-saved_object:${type}/${action}`; + }, + }, + } as Actions; +}; + +const createCheckSavedObjectsPrivileges = ( + spacesEnabled: boolean, + checkPrivilegesImpl: () => Promise +) => { + const mockAuditLogger = createMockAuditLogger(); + const mockErrors = createMockErrors(); + const mockActions = createMockActions(); + + const checkSavedObjectsPrivileges = jest.fn( + ensureSavedObjectsPrivilegesFactory({ + actionsService: mockActions, + auditLogger: mockAuditLogger, + checkPrivileges: { + atSpace: spacesEnabled + ? jest.fn().mockImplementationOnce(checkPrivilegesImpl) + : jest.fn().mockRejectedValue('atSpace should not be called'), + atSpaces: jest.fn().mockRejectedValue('atSpaces should not be called'), + globally: !spacesEnabled + ? jest.fn().mockImplementationOnce(checkPrivilegesImpl) + : jest.fn().mockRejectedValue('globally should not be called'), + }, + errors: mockErrors, + spacesEnabled, + }) + ); + + return { + mockActions, + mockAuditLogger, + mockErrors, + checkSavedObjectsPrivileges, + }; +}; + +describe('checkSavedObjectsPrivileges', () => { + describe('spaces disabled', () => { + it('checks globally, throwing forbidden error when not authorized', async () => { + const operation = 'create'; + const username = Symbol(); + const type = 'foo'; + const args = { + foo: Symbol(), + }; + + const checkPrivilegesImpl = jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.savedObject.get(type, operation)]: false, + }, + })); + + const { + checkSavedObjectsPrivileges, + mockAuditLogger, + mockActions, + mockErrors, + } = createCheckSavedObjectsPrivileges(false, checkPrivilegesImpl); + + await expect( + checkSavedObjectsPrivileges(type, operation, undefined, args) + ).rejects.toThrowError(mockErrors.forbiddenError); + + expect(checkPrivilegesImpl).toHaveBeenCalledWith([ + mockActions.savedObject.get(type, operation), + ]); + + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + operation, + [type], + [mockActions.savedObject.get(type, operation)], + args + ); + }); + + it('checks globally, throwing general error when checkPrivileges.globally throws an error', async () => { + const operation = 'create'; + const type = 'foo'; + const args = { + foo: Symbol(), + }; + + const checkPrivilegesImpl = jest.fn(async () => { + throw new Error('test'); + }); + + const { + checkSavedObjectsPrivileges, + mockAuditLogger, + mockActions, + mockErrors, + } = createCheckSavedObjectsPrivileges(false, checkPrivilegesImpl); + + await expect( + checkSavedObjectsPrivileges(type, operation, undefined, args) + ).rejects.toThrowError(mockErrors.generalError); + + expect(checkPrivilegesImpl).toHaveBeenCalledWith([ + mockActions.savedObject.get(type, operation), + ]); + + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + }); + + it('checks globally, resolving when authorized', async () => { + const operation = 'create'; + const username = Symbol(); + const type = 'foo'; + const args = { + foo: Symbol(), + }; + + const checkPrivilegesImpl = jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.savedObject.get(type, operation)]: true, + }, + })); + + const { + checkSavedObjectsPrivileges, + mockAuditLogger, + mockActions, + } = createCheckSavedObjectsPrivileges(false, checkPrivilegesImpl); + + await checkSavedObjectsPrivileges(type, operation, undefined, args); + + expect(checkPrivilegesImpl).toHaveBeenCalledWith([ + mockActions.savedObject.get(type, operation), + ]); + + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + operation, + [type], + args + ); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + }); + }); + + describe('spaces enabled at the default space', () => { + it('checks at the current space, using an undefined namespace, throwing forbidden error when not authorized', async () => { + const operation = 'create'; + const username = Symbol(); + const type = 'foo'; + const args = { + foo: Symbol(), + }; + + const checkPrivilegesImpl = jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.savedObject.get(type, operation)]: false, + }, + })); + + const { + checkSavedObjectsPrivileges, + mockAuditLogger, + mockActions, + mockErrors, + } = createCheckSavedObjectsPrivileges(true, checkPrivilegesImpl); + + await expect( + checkSavedObjectsPrivileges(type, operation, undefined, args) + ).rejects.toThrowError(mockErrors.forbiddenError); + + expect(checkPrivilegesImpl).toHaveBeenCalledWith(DEFAULT_SPACE_ID, [ + mockActions.savedObject.get(type, operation), + ]); + + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + operation, + [type], + [mockActions.savedObject.get(type, operation)], + args + ); + }); + + it('throws an error when "options.namespace" is a symbol', async () => { + const operation = 'create'; + const username = Symbol(); + const type = 'foo'; + const args = { + foo: Symbol(), + }; + + const checkPrivilegesImpl = jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.savedObject.get(type, operation)]: false, + }, + })); + + const { + checkSavedObjectsPrivileges, + mockActions, + mockErrors, + } = createCheckSavedObjectsPrivileges(true, checkPrivilegesImpl); + + await expect( + checkSavedObjectsPrivileges(type, operation, Symbol('foo'), args) + ).rejects.toThrowError(mockErrors.generalError); + }); + + it('checks at the current space, using an undefined namespace, throwing general error when checkPrivileges.atSpace throws an error', async () => { + const operation = 'create'; + const type = 'foo'; + const args = { + foo: Symbol(), + }; + + const checkPrivilegesImpl = jest.fn(async () => { + throw new Error('test'); + }); + + const { + checkSavedObjectsPrivileges, + mockAuditLogger, + mockActions, + mockErrors, + } = createCheckSavedObjectsPrivileges(true, checkPrivilegesImpl); + + await expect( + checkSavedObjectsPrivileges(type, operation, undefined, args) + ).rejects.toThrowError(mockErrors.generalError); + + expect(checkPrivilegesImpl).toHaveBeenCalledWith(DEFAULT_SPACE_ID, [ + mockActions.savedObject.get(type, operation), + ]); + + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + }); + + it('checks at the current space, using an undefined namespace, resolving when authorized', async () => { + const operation = 'create'; + const username = Symbol(); + const type = 'foo'; + const args = { + foo: Symbol(), + }; + + const checkPrivilegesImpl = jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.savedObject.get(type, operation)]: true, + }, + })); + + const { + checkSavedObjectsPrivileges, + mockAuditLogger, + mockActions, + } = createCheckSavedObjectsPrivileges(true, checkPrivilegesImpl); + + await checkSavedObjectsPrivileges(type, operation, undefined, args); + + expect(checkPrivilegesImpl).toHaveBeenCalledWith(DEFAULT_SPACE_ID, [ + mockActions.savedObject.get(type, operation), + ]); + + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + operation, + [type], + args + ); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + }); + }); + + describe('spaces enabled at the my-custom space', () => { + it('checks at the current space, using the my-custom namespace, throwing forbidden error when not authorized', async () => { + const operation = 'create'; + const username = Symbol(); + const type = 'foo'; + const args = { + foo: Symbol(), + }; + + const checkPrivilegesImpl = jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.savedObject.get(type, operation)]: false, + }, + })); + + const { + checkSavedObjectsPrivileges, + mockAuditLogger, + mockActions, + mockErrors, + } = createCheckSavedObjectsPrivileges(true, checkPrivilegesImpl); + + await expect( + checkSavedObjectsPrivileges(type, operation, 'my-custom', args) + ).rejects.toThrowError(mockErrors.forbiddenError); + + expect(checkPrivilegesImpl).toHaveBeenCalledWith('my-custom', [ + mockActions.savedObject.get(type, operation), + ]); + + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + operation, + [type], + [mockActions.savedObject.get(type, operation)], + args + ); + }); + + it('checks at the current space, using the my-custom namespace, throwing general error when checkPrivileges.atSpace throws an error', async () => { + const operation = 'create'; + const type = 'foo'; + const args = { + foo: Symbol(), + }; + + const checkPrivilegesImpl = jest.fn(async () => { + throw new Error('test'); + }); + + const { + checkSavedObjectsPrivileges, + mockAuditLogger, + mockActions, + mockErrors, + } = createCheckSavedObjectsPrivileges(true, checkPrivilegesImpl); + + await expect( + checkSavedObjectsPrivileges(type, operation, 'my-custom', args) + ).rejects.toThrowError(mockErrors.generalError); + + expect(checkPrivilegesImpl).toHaveBeenCalledWith('my-custom', [ + mockActions.savedObject.get(type, operation), + ]); + + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + }); + + it('checks at the current space, using the my-custom namespace, resolving when authorized', async () => { + const operation = 'create'; + const username = Symbol(); + const type = 'foo'; + const args = { + foo: Symbol(), + }; + + const checkPrivilegesImpl = jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.savedObject.get(type, operation)]: true, + }, + })); + + const { + checkSavedObjectsPrivileges, + mockAuditLogger, + mockActions, + } = createCheckSavedObjectsPrivileges(true, checkPrivilegesImpl); + + await checkSavedObjectsPrivileges(type, operation, 'my-custom', args); + + expect(checkPrivilegesImpl).toHaveBeenCalledWith('my-custom', [ + mockActions.savedObject.get(type, operation), + ]); + + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + operation, + [type], + args + ); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/ensure_saved_objects_privileges.ts b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/ensure_saved_objects_privileges.ts new file mode 100644 index 0000000000000..171010ba467cd --- /dev/null +++ b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/ensure_saved_objects_privileges.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { SavedObjectsNamespace, SavedObjectsErrorHelpers } from 'src/core/server'; +import { DEFAULT_SPACE_ID } from '../../../../../spaces/common/constants'; +import { CheckPrivileges, CheckPrivilegesAtResourceResponse } from '../check_privileges'; +import { Actions } from '../actions'; + +export type SavedObjectsOperation = + | 'create' + | 'update' + | 'delete' + | 'get' + | 'bulk_get' + | 'bulk_create' + | 'find'; + +interface Deps { + errors: typeof SavedObjectsErrorHelpers; + spacesEnabled: boolean; + checkPrivileges: CheckPrivileges; + actionsService: Actions; + auditLogger: any; +} + +export type EnsureSavedObjectsPrivileges = ( + typeOrTypes: string | string[] | undefined, + operation: SavedObjectsOperation, + namespace: SavedObjectsNamespace, + args: any +) => Promise; + +export function ensureSavedObjectsPrivilegesFactory(deps: Deps) { + const { errors, spacesEnabled, actionsService, auditLogger, checkPrivileges } = deps; + + const ensureSavedObjectsPrivileges: EnsureSavedObjectsPrivileges = async ( + typeOrTypes: string | string[] | undefined, + operation: SavedObjectsOperation, + namespace: SavedObjectsNamespace, + args: any + ) => { + const types = normalizeTypes(typeOrTypes); + const actionsToTypesMap = new Map( + types.map(type => [actionsService.savedObject.get(type, operation), type] as [string, string]) + ); + const actions = Array.from(actionsToTypesMap.keys()); + + let privilegeResponse: CheckPrivilegesAtResourceResponse; + + try { + if (spacesEnabled) { + const spaceId = namespaceToSpaceId(namespace, errors); + privilegeResponse = await checkPrivileges.atSpace(spaceId, actions); + } else { + privilegeResponse = await checkPrivileges.globally(actions); + } + } catch (error) { + const { reason } = get>(error, 'body.error', {}); + throw errors.decorateGeneralError(error, reason); + } + + const { hasAllRequested, username, privileges } = privilegeResponse; + if (hasAllRequested) { + auditLogger.savedObjectsAuthorizationSuccess(username, operation, types, args); + } else { + const missingPrivileges = getMissingPrivileges(privileges); + auditLogger.savedObjectsAuthorizationFailure( + username, + operation, + types, + missingPrivileges, + args + ); + + const msg = `Unable to ${operation} ${missingPrivileges + .map(privilege => actionsToTypesMap.get(privilege)) + .sort() + .join(',')}`; + throw errors.decorateForbiddenError(new Error(msg)); + } + }; + + return ensureSavedObjectsPrivileges; +} + +function normalizeTypes(typeOrTypes: string | string[] | undefined): string[] { + if (!typeOrTypes) { + return []; + } + if (Array.isArray(typeOrTypes)) { + return typeOrTypes; + } + return [typeOrTypes]; +} + +function namespaceToSpaceId( + namespace: SavedObjectsNamespace, + errors: typeof SavedObjectsErrorHelpers +) { + if (!namespace) { + return DEFAULT_SPACE_ID; + } + if (typeof namespace === 'string') { + return namespace; + } + + throw errors.decorateGeneralError( + new Error(`Unable to convert namespace (${String(namespace)}) to space id.`) + ); +} + +function getMissingPrivileges(response: Record): string[] { + return Object.keys(response).filter(privilege => !response[privilege]); +} diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/index.ts b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/index.ts new file mode 100644 index 0000000000000..1abe8eda0343d --- /dev/null +++ b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + createSecureSavedObjectsWrapperFactory, + SECURE_SOC_WRAPPER_PRIORITY, +} from './secure_saved_objects_client_wrapper_factory'; diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper.test.ts new file mode 100644 index 0000000000000..371c3cc71504f --- /dev/null +++ b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -0,0 +1,435 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + SecureSavedObjectsClientWrapper, + SecureSavedObjectsClientWrapperDeps, +} from './secure_saved_objects_client_wrapper'; +import { SavedObjectsClientContract } from 'src/core/server'; + +describe('#errors', () => { + test(`assigns errors from constructor to .errors`, () => { + const errors = Symbol(); + + const client = new SecureSavedObjectsClientWrapper(({ + errors, + } as unknown) as SecureSavedObjectsClientWrapperDeps); + + expect(client.errors).toBe(errors); + }); +}); + +describe('#create', () => { + test(`throws error when ensureSavedObjectsPrivileges throws an error`, async () => { + const type = 'foo-type'; + const namespace = 'foo-namespace'; + const testError = new Error('test'); + const ensureSavedObjectsPrivileges = jest.fn().mockImplementation(async () => { + throw testError; + }); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: (null as unknown) as SavedObjectsClientContract, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const attributes = Symbol(); + const options = { + namespace, + }; + + await expect(client.create(type, attributes as any, options as any)).rejects.toThrowError( + testError + ); + + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith(type, 'create', namespace, { + attributes, + options, + type, + }); + }); + + test(`returns result of baseClient.create when ensureSavedObjectsPrivileges succeeds`, async () => { + const type = 'foo-type'; + const namespace = 'foo-namespace'; + const returnValue = Symbol(); + const mockBaseClient = ({ + create: jest.fn().mockReturnValue(returnValue), + } as unknown) as SavedObjectsClientContract; + const ensureSavedObjectsPrivileges = jest.fn(); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: mockBaseClient, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const attributes = Symbol(); + const options = { + namespace, + }; + + const result = await client.create(type, attributes as any, options as any); + + expect(result).toBe(returnValue); + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith(type, 'create', namespace, { + attributes, + options, + type, + }); + expect(mockBaseClient.create).toHaveBeenCalledWith(type, attributes, options); + }); +}); + +describe('#bulkCreate', () => { + test(`throws error when ensureSavedObjectsPrivileges throws an error`, async () => { + const type1 = 'foo-type'; + const type2 = 'bar-type'; + const namespace = 'foo-namespace'; + const testError = new Error('test'); + const ensureSavedObjectsPrivileges = jest.fn().mockImplementation(async () => { + throw testError; + }); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: (null as unknown) as SavedObjectsClientContract, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const objects = [ + { type: type1, attributes: {} }, + { type: type1, attributes: {} }, + { type: type2, attributes: {} }, + ]; + const options = { namespace }; + + await expect(client.bulkCreate(objects, options as any)).rejects.toThrowError(testError); + + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith( + [type1, type2], + 'bulk_create', + namespace, + { + objects, + options, + } + ); + }); + + test(`returns result of baseClient.bulkCreate when ensureSavedObjectsPrivileges succeeds`, async () => { + const type1 = 'foo-type'; + const type2 = 'bar-type'; + const namespace = 'foo-namespace'; + const returnValue = Symbol(); + const mockBaseClient = ({ + bulkCreate: jest.fn().mockReturnValue(returnValue), + } as unknown) as SavedObjectsClientContract; + const ensureSavedObjectsPrivileges = jest.fn(); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: mockBaseClient, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const objects = [ + { type: type1, otherThing: 'sup', attributes: {} }, + { type: type2, otherThing: 'everyone', attributes: {} }, + ]; + const options = { namespace }; + + const result = await client.bulkCreate(objects, options as any); + + expect(result).toBe(returnValue); + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith( + [type1, type2], + 'bulk_create', + namespace, + { + objects, + options, + } + ); + expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith(objects, options); + }); +}); + +describe('#delete', () => { + test(`throws error when ensureSavedObjectsPrivileges throws an error`, async () => { + const type = 'foo-type'; + const namespace = 'foo-namespace'; + const testError = new Error('test'); + const ensureSavedObjectsPrivileges = jest.fn().mockImplementation(async () => { + throw testError; + }); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: (null as unknown) as SavedObjectsClientContract, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const id = Symbol(); + const options = { namespace }; + + await expect(client.delete(type, id as any, options as any)).rejects.toThrowError(testError); + + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith(type, 'delete', namespace, { + type, + id, + options, + }); + }); + + test(`returns result of baseClient.delete when ensureSavedObjectsPrivileges succeeds`, async () => { + const type = 'foo-type'; + const namespace = 'foo-namespace'; + const returnValue = Symbol(); + const mockBaseClient = ({ + delete: jest.fn().mockReturnValue(returnValue), + } as unknown) as SavedObjectsClientContract; + const ensureSavedObjectsPrivileges = jest.fn(); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: mockBaseClient, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const id = Symbol(); + const options = { namespace }; + + const result = await client.delete(type, id as any, options as any); + + expect(result).toBe(returnValue); + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith(type, 'delete', namespace, { + type, + options, + id, + }); + expect(mockBaseClient.delete).toHaveBeenCalledWith(type, id, options); + }); +}); + +describe('#find', () => { + test(`throws error when ensureSavedObjectsPrivileges throws an error`, async () => { + const type1 = 'foo-type'; + const type2 = 'bar-type'; + const namespace = 'foo-namespace'; + const testError = new Error('test'); + const ensureSavedObjectsPrivileges = jest.fn().mockImplementation(async () => { + throw testError; + }); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: (null as unknown) as SavedObjectsClientContract, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const options = { type: [type1, type2], namespace }; + + await expect(client.find(options)).rejects.toThrowError(testError); + + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith([type1, type2], 'find', namespace, { + options, + }); + }); + + test(`returns result of baseClient.find when ensureSavedObjectsPrivileges succeeds`, async () => { + const type = 'foo-type'; + const namespace = 'foo-namespace'; + const returnValue = Symbol(); + const mockBaseClient = ({ + find: jest.fn().mockReturnValue(returnValue), + } as unknown) as SavedObjectsClientContract; + const ensureSavedObjectsPrivileges = jest.fn(); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: mockBaseClient, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const options = { type, namespace }; + + const result = await client.find(options); + + expect(result).toBe(returnValue); + + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith(type, 'find', namespace, { + options, + }); + expect(mockBaseClient.find).toHaveBeenCalledWith(options); + }); +}); + +describe('#bulkGet', () => { + test(`throws error when ensureSavedObjectsPrivileges throws an error`, async () => { + const type1 = 'foo-type'; + const type2 = 'bar-type'; + const namespace = 'foo-namespace'; + const testError = new Error('test'); + const ensureSavedObjectsPrivileges = jest.fn().mockImplementation(async () => { + throw testError; + }); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: (null as unknown) as SavedObjectsClientContract, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const objects = [ + { type: type1, id: 'foo' }, + { type: type1, id: 'bar' }, + { type: type2, id: 'baz' }, + ]; + const options = { namespace }; + + await expect(client.bulkGet(objects, options as any)).rejects.toThrowError(testError); + + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith( + [type1, type2], + 'bulk_get', + namespace, + { + objects, + options, + } + ); + }); + + test(`returns result of baseClient.bulkGet when ensureSavedObjectsPrivileges succeeds`, async () => { + const type1 = 'foo-type'; + const type2 = 'bar-type'; + const namespace = 'foo-namespace'; + const returnValue = Symbol(); + const mockBaseClient = ({ + bulkGet: jest.fn().mockReturnValue(returnValue), + } as unknown) as SavedObjectsClientContract; + const ensureSavedObjectsPrivileges = jest.fn(); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: mockBaseClient, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const objects = [{ type: type1, id: 'foo-id' }, { type: type2, id: 'bar-id' }]; + const options = { namespace }; + + const result = await client.bulkGet(objects, options as any); + + expect(result).toBe(returnValue); + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith( + [type1, type2], + 'bulk_get', + namespace, + { + objects, + options, + } + ); + expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(objects, options); + }); +}); + +describe('#get', () => { + test(`throws error when ensureSavedObjectsPrivileges throws an error`, async () => { + const type = 'foo-type'; + const namespace = 'foo-namespace'; + const testError = new Error('test'); + const ensureSavedObjectsPrivileges = jest.fn().mockImplementation(async () => { + throw testError; + }); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: (null as unknown) as SavedObjectsClientContract, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const id = Symbol(); + const options = { namespace }; + + await expect(client.get(type, id as any, options as any)).rejects.toThrowError(testError); + + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith(type, 'get', namespace, { + type, + id, + options, + }); + }); + + test(`returns result of baseClient.get when ensureSavedObjectsPrivileges succeeds`, async () => { + const type = 'foo-type'; + const namespace = 'foo-namespace'; + const returnValue = Symbol(); + const mockBaseClient = ({ + get: jest.fn().mockReturnValue(returnValue), + } as unknown) as SavedObjectsClientContract; + const ensureSavedObjectsPrivileges = jest.fn(); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: mockBaseClient, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const id = Symbol(); + const options = { namespace }; + + const result = await client.get(type, id as any, options as any); + + expect(result).toBe(returnValue); + + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith(type, 'get', namespace, { + type, + id, + options, + }); + expect(mockBaseClient.get).toHaveBeenCalledWith(type, id, options); + }); +}); + +describe('#update', () => { + test(`throws error when ensureSavedObjectsPrivileges throws an error`, async () => { + const type = 'foo-type'; + const namespace = 'foo-namespace'; + const testError = new Error('test'); + const ensureSavedObjectsPrivileges = jest.fn().mockImplementation(async () => { + throw testError; + }); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: (null as unknown) as SavedObjectsClientContract, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const id = Symbol(); + const attributes = Symbol(); + const options = { namespace }; + + await expect( + client.update(type, id as any, attributes as any, options as any) + ).rejects.toThrowError(testError); + + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith(type, 'update', namespace, { + type, + id, + attributes, + options, + }); + }); + + test(`returns result of baseClient.update when ensureSavedObjectsPrivileges succeeds`, async () => { + const type = 'foo-type'; + const namespace = 'foo-namespace'; + const returnValue = Symbol(); + const mockBaseClient = ({ + update: jest.fn().mockReturnValue(returnValue), + } as unknown) as SavedObjectsClientContract; + const ensureSavedObjectsPrivileges = jest.fn(); + const client = new SecureSavedObjectsClientWrapper({ + baseClient: mockBaseClient, + ensureSavedObjectsPrivileges, + errors: null as any, + }); + const id = Symbol(); + const attributes = Symbol(); + const options = { namespace }; + + const result = await client.update(type, id as any, attributes as any, options as any); + + expect(result).toBe(returnValue); + + expect(ensureSavedObjectsPrivileges).toHaveBeenCalledWith(type, 'update', namespace, { + type, + id, + options, + attributes, + }); + expect(mockBaseClient.update).toHaveBeenCalledWith(type, id, attributes, options); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper.ts new file mode 100644 index 0000000000000..d1ce987233da1 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uniq } from 'lodash'; +import { + SavedObjectsClientContract, + SavedObjectsCreateOptions, + SavedObjectAttributes, + SavedObjectsBulkCreateObject, + SavedObjectsBaseOptions, + SavedObjectsFindOptions, + SavedObjectsBulkGetObject, + SavedObjectsFindResponse, + SavedObjectsBulkResponse, + SavedObject, + SavedObjectsUpdateOptions, +} from 'src/core/server'; +import { EnsureSavedObjectsPrivileges } from './ensure_saved_objects_privileges'; + +export interface SecureSavedObjectsClientWrapperDeps { + baseClient: SavedObjectsClientContract; + ensureSavedObjectsPrivileges: EnsureSavedObjectsPrivileges; + errors: SavedObjectsClientContract['errors']; +} +export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { + private baseClient: SavedObjectsClientContract; + + private ensureSavedObjectsPrivileges: EnsureSavedObjectsPrivileges; + + public errors: SavedObjectsClientContract['errors']; + + constructor(options: SecureSavedObjectsClientWrapperDeps) { + const { baseClient, ensureSavedObjectsPrivileges, errors } = options; + + this.errors = errors; + this.baseClient = baseClient; + this.ensureSavedObjectsPrivileges = ensureSavedObjectsPrivileges; + } + + public async create( + type: string, + attributes: T, + options: SavedObjectsCreateOptions = {} + ) { + await this.ensureSavedObjectsPrivileges(type, 'create', options.namespace, { + type, + attributes, + options, + }); + + return await this.baseClient.create(type, attributes, options); + } + + public async bulkCreate( + objects: Array>, + options: SavedObjectsCreateOptions = {} + ) { + const types = uniq(objects.map(o => o.type)); + await this.ensureSavedObjectsPrivileges(types, 'bulk_create', options.namespace, { + objects, + options, + }); + + return await this.baseClient.bulkCreate(objects, options); + } + + public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + await this.ensureSavedObjectsPrivileges(type, 'delete', options.namespace, { + type, + id, + options, + }); + + return await this.baseClient.delete(type, id, options); + } + + public async find( + options: SavedObjectsFindOptions = {} + ): Promise> { + await this.ensureSavedObjectsPrivileges(options.type, 'find', options.namespace, { options }); + + return this.baseClient.find(options); + } + + public async bulkGet( + objects: SavedObjectsBulkGetObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise> { + const types = uniq(objects.map(o => o.type)); + await this.ensureSavedObjectsPrivileges(types, 'bulk_get', options.namespace, { + objects, + options, + }); + + return await this.baseClient.bulkGet(objects, options); + } + + public async get( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> { + await this.ensureSavedObjectsPrivileges(type, 'get', options.namespace, { type, id, options }); + + return await this.baseClient.get(type, id, options); + } + + public async update( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ) { + await this.ensureSavedObjectsPrivileges(type, 'update', options.namespace, { + type, + id, + attributes, + options, + }); + + return await this.baseClient.update(type, id, attributes, options); + } +} diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper_factory.test.ts b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper_factory.test.ts new file mode 100644 index 0000000000000..1e856e750352c --- /dev/null +++ b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper_factory.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SpacesPlugin } from '../../../../../spaces/types'; +import { createSecureSavedObjectsWrapperFactory } from './secure_saved_objects_client_wrapper_factory'; +import { actionsFactory } from '../actions'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { Legacy } from 'kibana'; +import { createOptionalPlugin } from '../../../../../../server/lib/optional_plugin'; + +const config = { + get: jest.fn().mockImplementation(key => { + if (key === 'pkg.version') { + return '7.0.0'; + } + return ''; + }), +}; + +describe('createSecureSavedObjectsWrapperFactory', () => { + it('creates the Saved Objects Client Wrapper factory', () => { + const wrapperFactory = createSecureSavedObjectsWrapperFactory({ + spaces: createOptionalPlugin(config, 'xpack.spaces', {}, 'spaces'), + authorization: { + application: '.kibana-kibana', + mode: { + useRbacForRequest: () => true, + }, + checkPrivilegesWithRequest: jest.fn(), + checkPrivilegesDynamicallyWithRequest: jest + .fn() + .mockRejectedValue(new Error('should not be called')), + actions: actionsFactory(config), + privileges: null as any, + }, + savedObjects: null as any, + auditLogger: null as any, + }); + + expect(wrapperFactory).toBeInstanceOf(Function); + }); +}); + +describe('secureSavedObjectsClientWrapperFactory', () => { + it(`constructs 'checkPrivileges' using the incoming request`, async () => { + const authorization = { + application: '.kibana-kibana', + mode: { + useRbacForRequest: () => true, + }, + checkPrivilegesWithRequest: jest.fn(), + checkPrivilegesDynamicallyWithRequest: jest + .fn() + .mockRejectedValue(new Error('should not be called')), + actions: actionsFactory(config), + privileges: null as any, + }; + + const wrapperFactory = createSecureSavedObjectsWrapperFactory({ + spaces: createOptionalPlugin(config, 'xpack.spaces', {}, 'spaces'), + authorization, + savedObjects: { SavedObjectsClient: { error: {} as any } } as any, + auditLogger: null as any, + }); + + expect(authorization.checkPrivilegesWithRequest).not.toBeCalled(); + + const client = (Symbol() as unknown) as SavedObjectsClientContract; + const request = (Symbol() as unknown) as Legacy.Request; + + wrapperFactory({ client, request }); + + expect(authorization.checkPrivilegesWithRequest).toBeCalledWith(request); + }); +}); diff --git a/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper_factory.ts b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper_factory.ts new file mode 100644 index 0000000000000..5fd4cda8d4ea8 --- /dev/null +++ b/x-pack/legacy/plugins/security/server/lib/authorization/saved_objects/secure_saved_objects_client_wrapper_factory.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsService } from 'src/core/server'; +import { Legacy } from 'kibana'; +import { SavedObjectsClientWrapperOptions } from 'src/core/server/saved_objects/service/lib'; +import { OptionalPlugin } from '../../../../../../server/lib/optional_plugin'; +import { SpacesPlugin } from '../../../../../spaces/types'; +import { AuthorizationService } from '../service'; +import { ensureSavedObjectsPrivilegesFactory } from './ensure_saved_objects_privileges'; +import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; + +interface WrapperFactoryDeps { + authorization: AuthorizationService; + spaces: OptionalPlugin; + savedObjects: SavedObjectsService; + auditLogger: any; +} + +export const SECURE_SOC_WRAPPER_PRIORITY = Number.MAX_SAFE_INTEGER - 1; + +export const createSecureSavedObjectsWrapperFactory = ({ + authorization, + spaces, + savedObjects, + auditLogger, +}: WrapperFactoryDeps) => { + return function secureSavedObjectsWrapperFactory({ + client, + request, + }: SavedObjectsClientWrapperOptions) { + if (authorization.mode.useRbacForRequest(request)) { + const ensureSavedObjectsPrivileges = ensureSavedObjectsPrivilegesFactory({ + spacesEnabled: spaces.isEnabled, + checkPrivileges: authorization.checkPrivilegesWithRequest(request), + actionsService: authorization.actions, + errors: savedObjects.SavedObjectsClient.errors, + auditLogger, + }); + + return new SecureSavedObjectsClientWrapper({ + baseClient: client, + ensureSavedObjectsPrivileges, + errors: savedObjects.SavedObjectsClient.errors, + }); + } + + return client; + }; +}; diff --git a/x-pack/legacy/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.test.js b/x-pack/legacy/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.test.js deleted file mode 100644 index 7700306d8f09c..0000000000000 --- a/x-pack/legacy/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.test.js +++ /dev/null @@ -1,993 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; - -const createMockErrors = () => { - const forbiddenError = new Error('Mock ForbiddenError'); - const generalError = new Error('Mock GeneralError'); - - return { - forbiddenError, - decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), - generalError, - decorateGeneralError: jest.fn().mockReturnValue(generalError) - }; -}; - -const createMockAuditLogger = () => { - return { - savedObjectsAuthorizationFailure: jest.fn(), - savedObjectsAuthorizationSuccess: jest.fn(), - }; -}; - -const createMockActions = () => { - return { - savedObject: { - get(type, action) { - return `mock-saved_object:${type}/${action}`; - } - } - }; -}; - -describe('#errors', () => { - test(`assigns errors from constructor to .errors`, () => { - const errors = Symbol(); - - const client = new SecureSavedObjectsClientWrapper({ - checkPrivilegesDynamicallyWithRequest: () => {}, - errors - }); - - expect(client.errors).toBe(errors); - }); -}); - -describe(`spaces disabled`, () => { - describe('#create', () => { - test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivilegesDynamically = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivilegesDynamically); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivilegesDynamically).toHaveBeenCalledWith([mockActions.savedObject.get(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 mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'create')]: false, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const attributes = Symbol(); - const options = Symbol(); - - await expect(client.create(type, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'create')]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'create', - [type], - [mockActions.savedObject.get(type, 'create')], - { - type, - attributes, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.create when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - create: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'create')]: true, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const attributes = Symbol(); - const options = Symbol(); - - const result = await client.create(type, attributes, options); - - expect(result).toBe(returnValue); - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'create')]); - expect(mockBaseClient.create).toHaveBeenCalledWith(type, attributes, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'create', [type], { - type, - attributes, - options, - }); - }); - }); - - describe('#bulkCreate', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.bulkCreate([{ type }])).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(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 mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type1, 'bulk_create')]: false, - [mockActions.savedObject.get(type2, 'bulk_create')]: true, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const objects = [ - { type: type1 }, - { type: type1 }, - { type: type2 }, - ]; - const options = Symbol(); - - await expect(client.bulkCreate(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_create'), - mockActions.savedObject.get(type2, 'bulk_create'), - ]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_create', - [type1, type2], - [mockActions.savedObject.get(type1, 'bulk_create')], - { - objects, - options, - } - ); - }); - - test(`returns result of baseClient.bulkCreate when authorized`, async () => { - const username = Symbol(); - const type1 = 'foo'; - const type2 = 'bar'; - const returnValue = Symbol(); - const mockBaseClient = { - bulkCreate: jest.fn().mockReturnValue(returnValue) - }; - const mockActions = createMockActions(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type1, 'bulk_create')]: true, - [mockActions.savedObject.get(type2, 'bulk_create')]: true, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const objects = [ - { type: type1, otherThing: 'sup' }, - { type: type2, otherThing: 'everyone' }, - ]; - const options = Symbol(); - - const result = await client.bulkCreate(objects, options); - - expect(result).toBe(returnValue); - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_create'), - mockActions.savedObject.get(type2, 'bulk_create'), - ]); - expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith(objects, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_create', [type1, type2], { - objects, - options, - }); - }); - }); - - describe('#delete', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(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 mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'delete')]: false, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - - await expect(client.delete(type, id)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'delete')]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'delete', - [type], - [mockActions.savedObject.get(type, 'delete')], - { - type, - id, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of internalRepository.delete when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - delete: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'delete')]: true, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const options = Symbol(); - - const result = await client.delete(type, id, options); - - expect(result).toBe(returnValue); - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'delete')]); - expect(mockBaseClient.delete).toHaveBeenCalledWith(type, id, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete', [type], { - type, - id, - options, - }); - }); - }); - - describe('#find', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(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 mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'find')]: false, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const options = { type }; - - await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'find')]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type], - [mockActions.savedObject.get(type, 'find')], - { - options - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type1, 'find')]: false, - [mockActions.savedObject.get(type2, 'find')]: true, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const options = { type: [type1, type2] }; - - await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'find'), - mockActions.savedObject.get(type2, 'find') - ]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type1, type2], - [mockActions.savedObject.get(type1, 'find')], - { - options - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.find when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - find: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'find')]: true, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const options = { type }; - - const result = await client.find(options); - - expect(result).toBe(returnValue); - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'find')]); - expect(mockBaseClient.find).toHaveBeenCalledWith({ type }); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'find', [type], { - options, - }); - }); - }); - - describe('#bulkGet', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, '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 mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type1, 'bulk_get')]: false, - [mockActions.savedObject.get(type2, 'bulk_get')]: true, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const objects = [ - { type: type1 }, - { type: type1 }, - { type: type2 }, - ]; - const options = Symbol(); - - await expect(client.bulkGet(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_get'), - mockActions.savedObject.get(type2, 'bulk_get'), - ]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_get', - [type1, type2], - [mockActions.savedObject.get(type1, 'bulk_get')], - { - objects, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.bulkGet when authorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - bulkGet: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type1, 'bulk_get')]: true, - [mockActions.savedObject.get(type2, 'bulk_get')]: true, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const objects = [ - { type: type1, id: 'foo-id' }, - { type: type2, id: 'bar-id' }, - ]; - const options = Symbol(); - - const result = await client.bulkGet(objects, options); - - expect(result).toBe(returnValue); - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_get'), - mockActions.savedObject.get(type2, 'bulk_get'), - ]); - expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(objects, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { - objects, - options, - }); - }); - }); - - describe('#get', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(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 mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'get')]: false, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const options = Symbol(); - - await expect(client.get(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'get')]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'get', - [type], - [mockActions.savedObject.get(type, 'get')], - { - type, - id, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.get when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - get: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'get')]: true, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const options = Symbol(); - - const result = await client.get(type, id, options); - - expect(result).toBe(returnValue); - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'get')]); - expect(mockBaseClient.get).toHaveBeenCalledWith(type, id, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { - type, - id, - options - }); - }); - }); - - describe('#update', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(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 mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: false, - username, - privileges: { - [mockActions.savedObject.get(type, 'update')]: false, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const attributes = Symbol(); - const options = Symbol(); - - await expect(client.update(type, id, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'update')]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'update', - [type], - [mockActions.savedObject.get(type, 'update')], - { - type, - id, - attributes, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.update when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - update: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ - hasAllRequested: true, - username, - privileges: { - [mockActions.savedObject.get(type, 'update')]: true, - } - })); - const mockCheckPrivilegesDynamicallyWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const attributes = Symbol(); - const options = Symbol(); - - const result = await client.update(type, id, attributes, options); - - expect(result).toBe(returnValue); - expect(mockCheckPrivilegesDynamicallyWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'update')]); - expect(mockBaseClient.update).toHaveBeenCalledWith(type, id, attributes, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'update', [type], { - type, - id, - attributes, - options, - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/get_namespace.test.ts.snap b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/get_namespace.test.ts.snap new file mode 100644 index 0000000000000..9acbeaf83e166 --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/get_namespace.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getNamespace throws an error when an invalid namespace (() => null) is provided 1`] = `"unable to determine namespace from () => null. Expected string but found function"`; + +exports[`getNamespace throws an error when an invalid namespace ([object Object]) is provided 1`] = `"unable to determine namespace from [object Object]. Expected string but found object"`; + +exports[`getNamespace throws an error when an invalid namespace (1) is provided 1`] = `"unable to determine namespace from 1. Expected string but found number"`; + +exports[`getNamespace throws an error when an invalid namespace (Symbol()) is provided 1`] = `"unable to determine namespace from Symbol(). Expected string but found symbol"`; + +exports[`getNamespace throws an error when an invalid namespace (true) is provided 1`] = `"unable to determine namespace from true. Expected string but found boolean"`; diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap index e52af9a98001a..10af5650e128f 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap +++ b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap @@ -2,64 +2,36 @@ exports[`default space #bulkCreate throws error if objects type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`default space #bulkCreate throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`default space #bulkGet throws error if objects type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`default space #bulkGet throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #create throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`default space #create throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`default space #delete throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`default space #delete throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; exports[`default space #find if options.type isn't provided specifies options.type based on the types excluding the space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`default space #find throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`default space #find throws error if options.type is array containing space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; exports[`default space #find throws error if options.type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`default space #get throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`default space #get throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`default space #update throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`default space #update throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; exports[`space_1 space #bulkCreate throws error if objects type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`space_1 space #bulkCreate throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`space_1 space #bulkGet throws error if objects type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`space_1 space #bulkGet throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #create throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`space_1 space #create throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`space_1 space #delete throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`space_1 space #delete throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; exports[`space_1 space #find if options.type isn't provided specifies options.type based on the types excluding the space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`space_1 space #find throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`space_1 space #find throws error if options.type is array containing space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; exports[`space_1 space #find throws error if options.type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`space_1 space #get throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`space_1 space #get throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; -exports[`space_1 space #update throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - exports[`space_1 space #update throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/default_space_namespace.ts b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/default_space_namespace.ts new file mode 100644 index 0000000000000..07c0955dd4b17 --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/default_space_namespace.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DEFAULT_SPACE_NAMESPACE = Symbol('default_space_namespace'); diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/get_namespace.test.ts b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/get_namespace.test.ts new file mode 100644 index 0000000000000..94a282027380d --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/get_namespace.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DEFAULT_SPACE_ID } from '../../../common/constants'; +import { getNamespace } from './get_namespace'; +import { DEFAULT_SPACE_NAMESPACE } from './default_space_namespace'; + +describe('getNamespace', () => { + [1, true, () => null, {}, Symbol()].forEach(entry => { + it(`throws an error when an invalid namespace (${String(entry)}) is provided`, () => { + expect(() => + // @ts-ignore TS knows these values aren't allowed + getNamespace({ namespace: entry }, DEFAULT_SPACE_ID) + ).toThrowErrorMatchingSnapshot(); + }); + }); + describe(`without specifying 'options.namespace'`, () => { + it(`returns undefined for the default space`, () => { + expect(getNamespace({}, DEFAULT_SPACE_ID)).toBeUndefined(); + }); + + it(`returns the space id for non-default spaces`, () => { + expect(getNamespace({}, 'some-space')).toEqual('some-space'); + }); + }); + + describe(`when specifying 'options.namespace' with empty string`, () => { + it(`returns undefined for the default space`, () => { + expect(getNamespace({ namespace: '' }, DEFAULT_SPACE_ID)).toBeUndefined(); + }); + + it(`returns the space id for non-default spaces`, () => { + expect(getNamespace({ namespace: '' }, 'some-space')).toEqual('some-space'); + }); + }); + + describe(`when specifying 'options.namespace' with the DEFAULT_SPACE_NAMESPACE symbol`, () => { + it(`returns undefined for the default space`, () => { + expect( + getNamespace({ namespace: DEFAULT_SPACE_NAMESPACE }, DEFAULT_SPACE_ID) + ).toBeUndefined(); + }); + + it(`returns undefined for non-default spaces`, () => { + expect(getNamespace({ namespace: DEFAULT_SPACE_NAMESPACE }, 'some-space')).toBeUndefined(); + }); + }); + + describe(`when specifying 'options.namespace'`, () => { + it(`returns 'undefined' when specified via options.namespace`, () => { + expect(getNamespace({ namespace: 'override-space' }, DEFAULT_SPACE_ID)).toEqual( + 'override-space' + ); + }); + + it(`returns the namespace from options`, () => { + expect(getNamespace({ namespace: 'override-space' }, 'some-space')).toEqual('override-space'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/get_namespace.ts b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/get_namespace.ts new file mode 100644 index 0000000000000..82bfc3053e6f1 --- /dev/null +++ b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/get_namespace.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsBaseOptions } from 'src/core/server'; +import { DEFAULT_SPACE_ID } from '../../../common/constants'; +import { DEFAULT_SPACE_NAMESPACE } from './default_space_namespace'; + +export function getNamespace( + operationOptions: SavedObjectsBaseOptions, + currentSpaceId: string +): string | undefined { + if (operationOptions.namespace) { + if (operationOptions.namespace === DEFAULT_SPACE_NAMESPACE) { + return undefined; + } + + if (typeof operationOptions.namespace !== 'string') { + throw new TypeError( + `unable to determine namespace from ${String( + operationOptions.namespace + )}. Expected string but found ${typeof operationOptions.namespace}` + ); + } + + return operationOptions.namespace; + } + if (currentSpaceId === DEFAULT_SPACE_ID) { + return undefined; + } + return currentSpaceId; +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts index 0e298f4367d4a..c1eca967a6660 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; import { spacesServiceMock } from '../../new_platform/spaces_service/spaces_service.mock'; +import { DEFAULT_SPACE_NAMESPACE } from './default_space_namespace'; const types = ['foo', 'bar', 'space']; @@ -37,7 +38,7 @@ const createSpacesService = async (spaceId: string) => { ].forEach(currentSpace => { describe(`${currentSpace.id} space`, () => { describe('#get', () => { - test(`throws error if options.namespace is specified`, async () => { + test(`allows options.namespace to be specified`, async () => { const request = createMockRequest(); const baseClient = createMockClient(); const spacesService = await createSpacesService(currentSpace.id); @@ -49,9 +50,26 @@ const createSpacesService = async (spaceId: string) => { types, }); - await expect( - client.get('foo', '', { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await client.get('foo', '', { namespace: 'bar' }); + + expect(baseClient.get).toHaveBeenCalledWith('foo', '', { namespace: 'bar' }); + }); + + test(`allows options.namespace to be specified using the DEFAULT_SPACE_NAMESPACE symbol`, async () => { + const request = createMockRequest(); + const baseClient = createMockClient(); + const spacesService = await createSpacesService(currentSpace.id); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await client.get('foo', '', { namespace: DEFAULT_SPACE_NAMESPACE }); + + expect(baseClient.get).toHaveBeenCalledWith('foo', '', { namespace: undefined }); }); test(`throws error if type is space`, async () => { @@ -97,7 +115,7 @@ const createSpacesService = async (spaceId: string) => { }); describe('#bulkGet', () => { - test(`throws error if options.namespace is specified`, async () => { + test(`allows options.namespace to be specified`, async () => { const request = createMockRequest(); const baseClient = createMockClient(); const spacesService = await createSpacesService(currentSpace.id); @@ -109,9 +127,30 @@ const createSpacesService = async (spaceId: string) => { types, }); - await expect( - client.bulkGet([{ id: '', type: 'foo' }], { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await client.bulkGet([{ id: '', type: 'foo' }], { namespace: 'bar' }); + + expect(baseClient.bulkGet).toHaveBeenCalledWith([{ id: '', type: 'foo' }], { + namespace: 'bar', + }); + }); + + test(`allows options.namespace to be specified using the DEFAULT_SPACE_NAMESPACE symbol`, async () => { + const request = createMockRequest(); + const baseClient = createMockClient(); + const spacesService = await createSpacesService(currentSpace.id); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await client.bulkGet([{ id: '', type: 'foo' }], { namespace: DEFAULT_SPACE_NAMESPACE }); + + expect(baseClient.bulkGet).toHaveBeenCalledWith([{ id: '', type: 'foo' }], { + namespace: undefined, + }); }); test(`throws error if objects type is space`, async () => { @@ -127,7 +166,9 @@ const createSpacesService = async (spaceId: string) => { }); await expect( - client.bulkGet([{ id: '', type: 'foo' }, { id: '', type: 'space' }], { namespace: 'bar' }) + client.bulkGet([{ id: '', type: 'foo' }, { id: '', type: 'space' }], { + namespace: 'bar', + }) ).rejects.toThrowErrorMatchingSnapshot(); }); @@ -159,7 +200,24 @@ const createSpacesService = async (spaceId: string) => { }); describe('#find', () => { - test(`throws error if options.namespace is specified`, async () => { + test(`allows options.namespace to be specified`, async () => { + const request = createMockRequest(); + const baseClient = createMockClient(); + const spacesService = await createSpacesService(currentSpace.id); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await client.find({ type: ['foo'], namespace: 'bar' }); + + expect(baseClient.find).toHaveBeenCalledWith({ type: ['foo'], namespace: 'bar' }); + }); + + test(`allows options.namespace to be specified using the DEFAULT_SPACE_NAMESPACE symbol`, async () => { const request = createMockRequest(); const baseClient = createMockClient(); const spacesService = await createSpacesService(currentSpace.id); @@ -171,7 +229,9 @@ const createSpacesService = async (spaceId: string) => { types, }); - await expect(client.find({ namespace: 'bar' })).rejects.toThrowErrorMatchingSnapshot(); + await client.find({ type: ['foo'], namespace: DEFAULT_SPACE_NAMESPACE }); + + expect(baseClient.find).toHaveBeenCalledWith({ type: ['foo'], namespace: undefined }); }); test(`throws error if options.type is space`, async () => { @@ -279,7 +339,7 @@ const createSpacesService = async (spaceId: string) => { }); describe('#create', () => { - test(`throws error if options.namespace is specified`, async () => { + test(`allows options.namespace to be specified`, async () => { const request = createMockRequest(); const baseClient = createMockClient(); const spacesService = await createSpacesService(currentSpace.id); @@ -291,9 +351,26 @@ const createSpacesService = async (spaceId: string) => { types, }); - await expect( - client.create('foo', {}, { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await client.create('foo', {}, { namespace: 'bar' }); + + expect(baseClient.create).toHaveBeenCalledWith('foo', {}, { namespace: 'bar' }); + }); + + test(`allows options.namespace to be specified using the DEFAULT_SPACE_NAMESPACE symbol`, async () => { + const request = createMockRequest(); + const baseClient = createMockClient(); + const spacesService = await createSpacesService(currentSpace.id); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await client.create('foo', {}, { namespace: DEFAULT_SPACE_NAMESPACE }); + + expect(baseClient.create).toHaveBeenCalledWith('foo', {}, { namespace: undefined }); }); test(`throws error if type is space`, async () => { @@ -340,7 +417,7 @@ const createSpacesService = async (spaceId: string) => { }); describe('#bulkCreate', () => { - test(`throws error if options.namespace is specified`, async () => { + test(`allows options.namespace to be specified`, async () => { const request = createMockRequest(); const baseClient = createMockClient(); const spacesService = await createSpacesService(currentSpace.id); @@ -352,9 +429,36 @@ const createSpacesService = async (spaceId: string) => { types, }); - await expect( - client.bulkCreate([{ id: '', type: 'foo', attributes: {} }], { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await client.bulkCreate([{ id: '', type: 'foo', attributes: {} }], { + namespace: 'bar', + }); + + expect(baseClient.bulkCreate).toHaveBeenCalledWith( + [{ id: '', type: 'foo', attributes: {} }], + { namespace: 'bar' } + ); + }); + + test(`allows options.namespace to be specified using the DEFAULT_SPACE_NAMESPACE symbol`, async () => { + const request = createMockRequest(); + const baseClient = createMockClient(); + const spacesService = await createSpacesService(currentSpace.id); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await client.bulkCreate([{ id: '', type: 'foo', attributes: {} }], { + namespace: DEFAULT_SPACE_NAMESPACE, + }); + + expect(baseClient.bulkCreate).toHaveBeenCalledWith( + [{ id: '', type: 'foo', attributes: {} }], + { namespace: undefined } + ); }); test(`throws error if objects type is space`, async () => { @@ -405,7 +509,7 @@ const createSpacesService = async (spaceId: string) => { }); describe('#update', () => { - test(`throws error if options.namespace is specified`, async () => { + test(`allows options.namespace to be specified`, async () => { const request = createMockRequest(); const baseClient = createMockClient(); const spacesService = await createSpacesService(currentSpace.id); @@ -417,10 +521,26 @@ const createSpacesService = async (spaceId: string) => { types, }); - await expect( - // @ts-ignore - client.update(null, null, null, { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await client.update('foo', 'id', {}, { namespace: 'bar' }); + + expect(baseClient.update).toHaveBeenCalledWith('foo', 'id', {}, { namespace: 'bar' }); + }); + + test(`allows options.namespace to be specified using the DEFAULT_SPACE_NAMESPACE symbol`, async () => { + const request = createMockRequest(); + const baseClient = createMockClient(); + const spacesService = await createSpacesService(currentSpace.id); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await client.update('foo', 'id', {}, { namespace: DEFAULT_SPACE_NAMESPACE }); + + expect(baseClient.update).toHaveBeenCalledWith('foo', 'id', {}, { namespace: undefined }); }); test(`throws error if type is space`, async () => { @@ -468,7 +588,7 @@ const createSpacesService = async (spaceId: string) => { }); describe('#delete', () => { - test(`throws error if options.namespace is specified`, async () => { + test(`allows options.namespace to be specified`, async () => { const request = createMockRequest(); const baseClient = createMockClient(); const spacesService = await createSpacesService(currentSpace.id); @@ -480,10 +600,26 @@ const createSpacesService = async (spaceId: string) => { types, }); - await expect( - // @ts-ignore - client.delete(null, null, { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await client.delete('foo', 'id', { namespace: 'bar' }); + + expect(baseClient.delete).toHaveBeenCalledWith('foo', 'id', { namespace: 'bar' }); + }); + + test(`allows options.namespace to be specified using the DEFAULT_SPACE_NAMESPACE symbol`, async () => { + const request = createMockRequest(); + const baseClient = createMockClient(); + const spacesService = await createSpacesService(currentSpace.id); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await client.delete('foo', 'id', { namespace: DEFAULT_SPACE_NAMESPACE }); + + expect(baseClient.delete).toHaveBeenCalledWith('foo', 'id', { namespace: undefined }); }); test(`throws error if type is space`, async () => { diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts index 3ce586caa5dae..ad86bd5b9ff83 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts @@ -14,8 +14,8 @@ import { SavedObjectsFindOptions, SavedObjectsUpdateOptions, } from 'src/core/server'; -import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service'; +import { getNamespace } from './get_namespace'; interface SpacesSavedObjectsClientOptions { baseClient: SavedObjectsClientContract; @@ -32,20 +32,6 @@ const coerceToArray = (param: string | string[]) => { return [param]; }; -const getNamespace = (spaceId: string) => { - if (spaceId === DEFAULT_SPACE_ID) { - return undefined; - } - - return spaceId; -}; - -const throwErrorIfNamespaceSpecified = (options: any) => { - if (options.namespace) { - throw new Error('Spaces currently determines the namespaces'); - } -}; - const throwErrorIfTypeIsSpace = (type: string) => { if (type === 'space') { throw new Error('Spaces can not be accessed using the SavedObjectsClient'); @@ -62,6 +48,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { private readonly client: SavedObjectsClientContract; private readonly spaceId: string; private readonly types: string[]; + public readonly errors: SavedObjectsClientContract['errors']; constructor(options: SpacesSavedObjectsClientOptions) { @@ -90,11 +77,10 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { options: SavedObjectsCreateOptions = {} ) { throwErrorIfTypeIsSpace(type); - throwErrorIfNamespaceSpecified(options); return await this.client.create(type, attributes, { ...options, - namespace: getNamespace(this.spaceId), + namespace: getNamespace(options, this.spaceId), }); } @@ -112,11 +98,10 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { options: SavedObjectsBaseOptions = {} ) { throwErrorIfTypesContainsSpace(objects.map(object => object.type)); - throwErrorIfNamespaceSpecified(options); return await this.client.bulkCreate(objects, { ...options, - namespace: getNamespace(this.spaceId), + namespace: getNamespace(options, this.spaceId), }); } @@ -131,11 +116,10 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { */ public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { throwErrorIfTypeIsSpace(type); - throwErrorIfNamespaceSpecified(options); return await this.client.delete(type, id, { ...options, - namespace: getNamespace(this.spaceId), + namespace: getNamespace(options, this.spaceId), }); } @@ -160,14 +144,12 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { throwErrorIfTypesContainsSpace(coerceToArray(options.type)); } - throwErrorIfNamespaceSpecified(options); - return await this.client.find({ ...options, type: (options.type ? coerceToArray(options.type) : this.types).filter( type => type !== 'space' ), - namespace: getNamespace(this.spaceId), + namespace: getNamespace(options, this.spaceId), }); } @@ -190,11 +172,10 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { options: SavedObjectsBaseOptions = {} ) { throwErrorIfTypesContainsSpace(objects.map(object => object.type)); - throwErrorIfNamespaceSpecified(options); return await this.client.bulkGet(objects, { ...options, - namespace: getNamespace(this.spaceId), + namespace: getNamespace(options, this.spaceId), }); } @@ -209,11 +190,10 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { */ public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { throwErrorIfTypeIsSpace(type); - throwErrorIfNamespaceSpecified(options); return await this.client.get(type, id, { ...options, - namespace: getNamespace(this.spaceId), + namespace: getNamespace(options, this.spaceId), }); } @@ -234,11 +214,10 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { options: SavedObjectsUpdateOptions = {} ) { throwErrorIfTypeIsSpace(type); - throwErrorIfNamespaceSpecified(options); return await this.client.update(type, id, attributes, { ...options, - namespace: getNamespace(this.spaceId), + namespace: getNamespace(options, this.spaceId), }); } } diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts b/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts index 5689804c125bd..e8ac9ba7bf3f3 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts +++ b/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts @@ -119,7 +119,7 @@ export class Plugin { const { addScopedSavedObjectsClientWrapperFactory, types } = core.savedObjects; addScopedSavedObjectsClientWrapperFactory( - Number.MAX_SAFE_INTEGER - 1, + Number.MIN_SAFE_INTEGER, spacesSavedObjectsClientWrapperFactory(spacesService, types) ); diff --git a/x-pack/typings/core.d.ts b/x-pack/typings/core.d.ts new file mode 100644 index 0000000000000..97d50fa15562a --- /dev/null +++ b/x-pack/typings/core.d.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SavedObjectsClient } from 'src/core/server/saved_objects'; + +declare module 'src/core/server/saved_objects' { + type SavedObjectsNamespace = string | undefined | symbol; + interface SavedObjectsBaseOptions { + namespace?: SavedObjectsNamespace; + } +}