diff --git a/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts b/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts index d1bec41373c9d..b937508094a84 100644 --- a/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts +++ b/packages/core/plugins/core-plugins-server-internal/src/plugin_context.ts @@ -234,7 +234,9 @@ export function createPluginSetupContext( }, savedObjects: { setClientFactoryProvider: deps.savedObjects.setClientFactoryProvider, - addClientWrapper: deps.savedObjects.addClientWrapper, + setEncryptionExtension: deps.savedObjects.setEncryptionExtension, + setSecurityExtension: deps.savedObjects.setSecurityExtension, + setSpacesExtension: deps.savedObjects.setSpacesExtension, registerType: deps.savedObjects.registerType, getKibanaIndex: deps.savedObjects.getKibanaIndex, }, diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/base.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/base.ts index 6414b206a672b..74d5223def315 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/base.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/base.ts @@ -8,7 +8,12 @@ import { SimpleSavedObject } from '../simple_saved_object'; -/** @public */ +/** + * Batch response for simple saved objects + * + * @public + */ export interface SavedObjectsBatchResponse { + /** Array of simple saved objects */ savedObjects: Array>; } diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_create.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_create.ts index 4e4190cb55675..6d5ce23205ef8 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_create.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_create.ts @@ -9,18 +9,23 @@ import type { SavedObjectsCreateOptions } from './create'; /** - * @param type - Create a SavedObject of the given type - * @param attributes - Create a SavedObject with the given attributes + * Per-object parameters for bulk create operation * * @public */ export interface SavedObjectsBulkCreateObject extends SavedObjectsCreateOptions { + /** Create a SavedObject of this type. */ type: string; + /** Attributes for the saved object to be created. */ attributes: T; } -/** @public */ +/** + * Options for bulk create operation + * + * @public + * */ export interface SavedObjectsBulkCreateOptions { - /** If a document with the given `id` already exists, overwrite it's contents (default=false). */ + /** If a document with the given `id` already exists, overwrite its contents (default=false). */ overwrite?: boolean; } diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_delete.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_delete.ts index 1e4b5d2268dea..532e900338d8a 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_delete.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_delete.ts @@ -8,20 +8,38 @@ import { SavedObjectError } from '@kbn/core-saved-objects-common'; -/** @public */ +/** + * Options for bulk delete operation + * + * @public + */ export interface SavedObjectsBulkDeleteOptions { + /** Force deletion of any objects that exist in multiple namespaces (default=false) */ force?: boolean; } -/** @public */ +/** + * Single item within the statuses array of the bulk delete response + * + * @public + */ export interface SavedObjectsBulkDeleteResponseItem { + /** saved object id */ id: string; + /** saved object type */ type: string; + /** true if the delete operation succeeded*/ success: boolean; + /** error from delete operation (undefined if no error) */ error?: SavedObjectError; } -/** @public */ +/** + * Return type of the Saved Objects `bulkDelete()` method. + * + * @public + */ export interface SavedObjectsBulkDeleteResponse { + /** array of statuses per object */ statuses: SavedObjectsBulkDeleteResponseItem[]; } diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_resolve.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_resolve.ts index 58316983c7a8d..c5ba482525298 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_resolve.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_resolve.ts @@ -8,7 +8,12 @@ import type { ResolvedSimpleSavedObject } from './resolve'; -/** @public */ +/** + * Return type of the Saved Objects `bulkResolve()` method. + * + * @public + */ export interface SavedObjectsBulkResolveResponse { + /** Array of {@link ResolvedSimpleSavedObject} that were resolved */ resolved_objects: Array>; } diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_update.ts index f5ba2cc249c19..c819fb1ac448d 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_update.ts @@ -8,16 +8,33 @@ import type { SavedObjectReference } from '@kbn/core-saved-objects-common'; -/** @public */ +/** + * Per-object parameters for bulk update operation + * + * @public + */ export interface SavedObjectsBulkUpdateObject { + /** Type of the saved object to update */ type: string; + /** ID of the saved object to update */ id: string; + /** The attributes to update */ attributes: T; + /** The version string for the saved object */ version?: string; + /** Array of references to other saved objects */ references?: SavedObjectReference[]; } -/** @public */ +/** + * Options for bulk update operation + * + * @public + * */ export interface SavedObjectsBulkUpdateOptions { + /** + * The namespace from which to apply the bulk update operation + * Not permitted if spaces extension is enabled + */ namespace?: string; } diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/create.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/create.ts index fb2e70d7d1ebd..fb6a18219e5bf 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/create.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/create.ts @@ -11,7 +11,11 @@ import type { SavedObjectsMigrationVersion, } from '@kbn/core-saved-objects-common'; -/** @public */ +/** + * Options for creating a saved object. + * + * @public + */ export interface SavedObjectsCreateOptions { /** * (Not recommended) Specify an id instead of having the saved objects service generate one for you. @@ -23,5 +27,6 @@ export interface SavedObjectsCreateOptions { migrationVersion?: SavedObjectsMigrationVersion; /** A semver value that is used when upgrading objects between Kibana versions. */ coreMigrationVersion?: string; + /** Array of referenced saved objects. */ references?: SavedObjectReference[]; } diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/delete.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/delete.ts index 598a35a0aad41..c43dbec6845b3 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/delete.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/delete.ts @@ -6,8 +6,12 @@ * Side Public License, v 1. */ -/** @public */ +/** + * Options for deleting a saved object. + * + * @public + */ export interface SavedObjectsDeleteOptions { - /** Force deletion of an object that exists in multiple namespaces */ + /** Force deletion of an object that exists in multiple namespaces (default=false) */ force?: boolean; } diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/find.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/find.ts index 7f555764124a0..39d99a6ee6e86 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/find.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/find.ts @@ -9,7 +9,11 @@ import type { SavedObjectsFindOptions as SavedObjectFindOptionsServer } from '@kbn/core-saved-objects-api-server'; import type { SavedObjectsBatchResponse } from './base'; +export type { SavedObjectsFindOptionsReference } from '@kbn/core-saved-objects-api-server'; + /** + * Browser options for finding saved objects + * * @public */ export type SavedObjectsFindOptions = Omit< @@ -24,16 +28,12 @@ export type SavedObjectsFindOptions = Omit< */ export interface SavedObjectsFindResponse extends SavedObjectsBatchResponse { + /** aggregations from the search query */ aggregations?: A; + /** total number of results */ total: number; + /** number of results per page */ perPage: number; + /** current page in results*/ page: number; } - -/** - * @public - */ -export interface SavedObjectsFindOptionsReference { - type: string; - id: string; -} diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/resolve.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/resolve.ts index c2383d7cb50d5..d2868ae48ced1 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/resolve.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/resolve.ts @@ -7,7 +7,7 @@ */ import type { SavedObjectsResolveResponse } from '@kbn/core-saved-objects-api-server'; -import { SimpleSavedObject } from '../simple_saved_object'; +import type { SimpleSavedObject } from '../simple_saved_object'; /** * This interface is a very simple wrapper for SavedObjects resolved from the server diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/update.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/update.ts index dd9a6dc523229..f8095e35df1e5 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/update.ts @@ -8,9 +8,16 @@ import type { SavedObjectReference } from '@kbn/core-saved-objects-common'; -/** @public */ +/** + * Options for updating a saved object + * + * @public + */ export interface SavedObjectsUpdateOptions { + /** version of the saved object */ version?: string; + /** Alternative attributes for the saved object if upserting */ upsert?: Attributes; + /** Array of references to other saved objects */ references?: SavedObjectReference[]; } diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/saved_objects_client.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/saved_objects_client.ts index d222770a8579d..9ff01104c5ba0 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/saved_objects_client.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/saved_objects_client.ts @@ -33,7 +33,12 @@ import type { SimpleSavedObject } from './simple_saved_object'; */ export interface SavedObjectsClientContract { /** - * Persists an object + * Creates an object + * + * @param {string} type - the type of object to create + * @param {string} attributes - the attributes of the object + * @param {string} options {@link SavedObjectsCreateOptions} + * @returns The result of the create operation - the created saved object */ create( type: string, @@ -42,7 +47,10 @@ export interface SavedObjectsClientContract { ): Promise>; /** - * Creates multiple documents at once + * Creates multiple objects at once + * + * @param {string} objects - an array of objects containing type, attributes + * @param {string} options {@link SavedObjectsBulkCreateOptions} * @returns The result of the create operation containing created saved objects. */ bulkCreate( @@ -52,6 +60,11 @@ export interface SavedObjectsClientContract { /** * Deletes an object + * + * @param {string} type - the type the of object to delete + * @param {string} id - the id of the object to delete + * @param {string} options {@link SavedObjectsDeleteOptions} + * @param {string} options.force - required to delete objects shared to multiple spaces */ delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; @@ -69,8 +82,8 @@ export interface SavedObjectsClientContract { /** * Search for objects * - * @param {object} [options={}] - * @property {string} options.type + * @param {object} [options={}] {@link SavedObjectsFindOptions} + * @property {string} options.type - the type or array of types to find * @property {string} options.search * @property {string} options.searchFields - see Elasticsearch Simple Query String * Query field argument for more information @@ -87,8 +100,8 @@ export interface SavedObjectsClientContract { /** * Fetches a single object * - * @param {string} type - * @param {string} id + * @param {string} type - the type of the object to get + * @param {string} id - the ID of the object to get * @returns The saved object for the given type and id. */ get(type: string, id: string): Promise>; @@ -110,8 +123,8 @@ export interface SavedObjectsClientContract { /** * Resolves a single object * - * @param {string} type - * @param {string} id + * @param {string} type - the type of the object to resolve + * @param {string} id - the ID of the object to resolve * @returns The resolve result for the saved object for the given type and id. * * @note Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the @@ -144,13 +157,13 @@ export interface SavedObjectsClientContract { /** * Updates an object * - * @param {string} type - * @param {string} id - * @param {object} attributes - * @param {object} options + * @param {string} type - the type of the object to update + * @param {string} id - the ID of the object to update + * @param {object} attributes - the attributes to update + * @param {object} options {@link SavedObjectsUpdateOptions} * @prop {integer} options.version - ensures version matches that of persisted object * @prop {object} options.migrationVersion - The optional migrationVersion of this document - * @returns + * @returns the udpated simple saved object */ update( type: string, @@ -162,8 +175,8 @@ export interface SavedObjectsClientContract { /** * Update multiple documents at once * - * @param {array} objects - [{ type, id, attributes, options: { version, references } }] - * @returns The result of the update operation containing both failed and updated saved objects. + * @param {array} objects - an array of objects containing type, id, attributes, and references + * @returns the result of the bulk update operation containing both failed and updated saved objects. */ bulkUpdate( objects: SavedObjectsBulkUpdateObject[] diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts index 61e4359bf3d74..2932d4f5675a8 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/simple_saved_object.ts @@ -18,15 +18,25 @@ import type { SavedObject as SavedObjectType } from '@kbn/core-saved-objects-com * @public */ export interface SimpleSavedObject { + /** attributes of the object, templated */ attributes: T; + /** version of the saved object */ _version?: SavedObjectType['version']; + /** ID of the saved object */ id: SavedObjectType['id']; + /** Type of the saved object */ type: SavedObjectType['type']; + /** Migration version of the saved object */ migrationVersion: SavedObjectType['migrationVersion']; + /** Core migration version of the saved object */ coreMigrationVersion: SavedObjectType['coreMigrationVersion']; + /** Error associated with this object, undefined if no error */ error: SavedObjectType['error']; + /** References to other saved objects */ references: SavedObjectType['references']; + /** The date this object was last updated */ updatedAt: SavedObjectType['updated_at']; + /** The date this object was created */ createdAt: SavedObjectType['created_at']; /** * Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with @@ -34,13 +44,38 @@ export interface SimpleSavedObject { */ namespaces: SavedObjectType['namespaces']; + /** + * Gets an attribute of this object + * + * @param {string} key - the name of the attribute + * @returns The value of the attribute. + */ get(key: string): any; + /** + * Sets an attribute of this object + * + * @param {string} key - the name of the attribute + * @param {string} value - the value for the attribute + * @returns The updated attributes of this object. + */ set(key: string, value: any): T; + /** + * Checks if this object has an attribute + * + * @param {string} key - the name of the attribute + * @returns true if the attribute exists. + */ has(key: string): boolean; + /** + * Saves this object + */ save(): Promise>; + /** + * Deletes this object + */ delete(): Promise<{}>; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.test.ts index 86e1e82d12a6f..55080f8e9e4e2 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.test.ts @@ -22,10 +22,24 @@ import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-inte import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; import { ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE, - CollectMultiNamespaceReferencesParams, + type CollectMultiNamespaceReferencesParams, } from './collect_multi_namespace_references'; import { collectMultiNamespaceReferences } from './collect_multi_namespace_references'; import type { CreatePointInTimeFinderFn } from './point_in_time_finder'; +import { AuditAction, type ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server'; + +import { + authMap, + enforceError, + typeMapsAreEqual, + setsAreEqual, + setupCheckAuthorized, + setupCheckUnauthorized, + setupEnforceFailure, + setupEnforceSuccess, + setupRedactPassthrough, +} from '../test_helpers/repository.test.common'; +import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock'; const SPACES = ['default', 'another-space']; const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 }; @@ -50,7 +64,8 @@ describe('collectMultiNamespaceReferences', () => { /** Sets up the type registry, saved objects client, etc. and return the full parameters object to be passed to `collectMultiNamespaceReferences` */ function setup( objects: SavedObjectsCollectMultiNamespaceReferencesObject[], - options: SavedObjectsCollectMultiNamespaceReferencesOptions = {} + options: SavedObjectsCollectMultiNamespaceReferencesOptions = {}, + securityExtension?: ISavedObjectsSecurityExtension | undefined ): CollectMultiNamespaceReferencesParams { const registry = typeRegistryMock.create(); registry.isMultiNamespace.mockImplementation( @@ -65,8 +80,8 @@ describe('collectMultiNamespaceReferences', () => { (type) => [MULTI_NAMESPACE_OBJ_TYPE_1, MULTI_NAMESPACE_HIDDEN_OBJ_TYPE].includes(type) // MULTI_NAMESPACE_OBJ_TYPE_2 and NON_MULTI_NAMESPACE_TYPE are omitted ); client = elasticsearchClientMock.createElasticsearchClient(); - const serializer = new SavedObjectsSerializer(registry); + return { registry, allowedTypes: [ @@ -78,6 +93,7 @@ describe('collectMultiNamespaceReferences', () => { serializer, getIndexForType: (type: string) => `index-for-${type}`, createPointInTimeFinder: jest.fn() as CreatePointInTimeFinderFn, + securityExtension, objects, options, }; @@ -287,6 +303,7 @@ describe('collectMultiNamespaceReferences', () => { // obj3 is excluded from the results ]); }); + it(`handles 404 responses that don't come from Elasticsearch`, async () => { const createEsUnavailableNotFoundError = () => { return SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); @@ -447,4 +464,213 @@ describe('collectMultiNamespaceReferences', () => { ); }); }); + + describe('with security enabled', () => { + const mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension(); + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const obj2 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-2' }; + const obj3 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-3' }; + const objects = [obj1, obj2]; + const obj1LegacySpaces = ['space-1', 'space-2', 'space-3', 'space-4']; + let params: CollectMultiNamespaceReferencesParams; + + beforeEach(() => { + params = setup([obj1, obj2], {}, mockSecurityExt); + mockMgetResults({ found: true, references: [obj3] }, { found: true, references: [] }); // results for obj1 and obj2 + mockMgetResults({ found: true, references: [] }); // results for obj3 + mockFindLegacyUrlAliases.mockResolvedValue( + new Map([ + [`${obj1.type}:${obj1.id}`, new Set(obj1LegacySpaces)], + // the result map does not contain keys for obj2 or obj3 because we did not find any aliases for those objects + ]) + ); + }); + + afterEach(() => { + mockSecurityExt.checkAuthorization.mockReset(); + mockSecurityExt.enforceAuthorization.mockReset(); + mockSecurityExt.redactNamespaces.mockReset(); + mockSecurityExt.addAuditEvent.mockReset(); + }); + + describe(`errors`, () => { + test(`propagates decorated error when not authorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + // Unlike other functions, it doesn't validate the level of authorization first, so we need to + // carry on and mock the enforce function as well to create an unauthorized condition + setupEnforceFailure(mockSecurityExt); + + await expect(collectMultiNamespaceReferences(params)).rejects.toThrow(enforceError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`adds audit event per object when not successful`, async () => { + setupCheckUnauthorized(mockSecurityExt); + // Unlike other functions, it doesn't validate the level of authorization first, so we need to + // carry on and mock the enforce function as well to create an unauthorized condition + setupEnforceFailure(mockSecurityExt); + + await expect(collectMultiNamespaceReferences(params)).rejects.toThrow(enforceError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.COLLECT_MULTINAMESPACE_REFERENCES, + savedObject: { type: obj.type, id: obj.id }, + error: enforceError, + }); + }); + }); + }); + + describe('checks privileges', () => { + beforeEach(() => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + }); + test(`in the default state`, async () => { + await expect(collectMultiNamespaceReferences(params)).rejects.toThrow(enforceError); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedSpaces = new Set(['default', ...SPACES, ...obj1LegacySpaces]); + const { spaces: actualSpaces } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + const expectedTypesAndSpaces = new Map([[objects[0].type, new Set(['default'])]]); + const { typesAndSpaces: actualTypesAndSpaces } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + }); + + test(`in a non-default state`, async () => { + const namespace = 'space-X'; + await expect( + collectMultiNamespaceReferences({ ...params, options: { namespace } }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedSpaces = new Set([namespace, ...SPACES, ...obj1LegacySpaces]); + const { spaces: actualSpaces } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + const expectedTypesAndSpaces = new Map([[objects[0].type, new Set([namespace])]]); + const { typesAndSpaces: actualTypesAndSpaces } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + }); + + test(`with purpose 'collectMultiNamespaceReferences'`, async () => { + const options: SavedObjectsCollectMultiNamespaceReferencesOptions = { + purpose: 'collectMultiNamespaceReferences', + }; + + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect(collectMultiNamespaceReferences({ ...params, options })).rejects.toThrow( + enforceError + ); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.checkAuthorization).toBeCalledWith( + expect.objectContaining({ + actions: new Set(['bulk_get']), + }) + ); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`with purpose 'updateObjectsSpaces'`, async () => { + const options: SavedObjectsCollectMultiNamespaceReferencesOptions = { + purpose: 'updateObjectsSpaces', + }; + + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect(collectMultiNamespaceReferences({ ...params, options })).rejects.toThrow( + enforceError + ); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.checkAuthorization).toBeCalledWith( + expect.objectContaining({ + actions: new Set(['share_to_space']), + }) + ); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + }); + + describe('success', () => { + beforeEach(async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + await collectMultiNamespaceReferences(params); + }); + test(`calls redactNamespaces with type, spaces, and authorization map`, async () => { + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedSpaces = new Set(['default', ...SPACES, ...obj1LegacySpaces]); + const { spaces: actualSpaces } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + + const resultObjects = [obj1, obj2, obj3]; + + // enforce is called once for all objects/spaces, then once per object + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes( + 1 + resultObjects.length + ); + const expectedTypesAndSpaces = new Map([[objects[0].type, new Set(['default'])]]); + const { typesAndSpaces: actualTypesAndSpaces } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + + // Redact is called once per object, but an additional time for object 1 because it has legacy URL aliases in another set of spaces + expect(mockSecurityExt.redactNamespaces).toBeCalledTimes(resultObjects.length + 1); + const expectedRedactParams = [ + { type: obj1.type, spaces: SPACES }, + { type: obj1.type, spaces: obj1LegacySpaces }, + { type: obj2.type, spaces: SPACES }, + { type: obj3.type, spaces: SPACES }, + ]; + + expectedRedactParams.forEach((expected, i) => { + const { savedObject, typeMap } = mockSecurityExt.redactNamespaces.mock.calls[i][0]; + expect(savedObject).toEqual( + expect.objectContaining({ + type: expected.type, + namespaces: expected.spaces, + }) + ); + expect(typeMap).toBe(authMap); + }); + }); + + test(`adds audit event per object when successful`, async () => { + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + + const resultObjects = [obj1, obj2, obj3]; + + // enforce is called once for all objects/spaces, then once per object + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes( + 1 + resultObjects.length + ); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(resultObjects.length); + resultObjects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.COLLECT_MULTINAMESPACE_REFERENCES, + savedObject: { type: obj.type, id: obj.id }, + error: undefined, + }); + }); + }); + }); + }); }); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.ts index 16343ee38c4fd..ffe3950394dd8 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.ts @@ -14,8 +14,12 @@ import type { SavedObjectsCollectMultiNamespaceReferencesResponse, SavedObjectReferenceWithContext, } from '@kbn/core-saved-objects-api-server'; -import type { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server'; -import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server'; +import { + AuditAction, + type ISavedObjectsSecurityExtension, + type ISavedObjectTypeRegistry, +} from '@kbn/core-saved-objects-server'; +import { SavedObjectsErrorHelpers, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; import { type SavedObjectsSerializer, getObjectKey, @@ -54,6 +58,7 @@ export interface CollectMultiNamespaceReferencesParams { serializer: SavedObjectsSerializer; getIndexForType: (type: string) => string; createPointInTimeFinder: CreatePointInTimeFinderFn; + securityExtension: ISavedObjectsSecurityExtension | undefined; objects: SavedObjectsCollectMultiNamespaceReferencesObject[]; options?: SavedObjectsCollectMultiNamespaceReferencesOptions; } @@ -94,17 +99,17 @@ export async function collectMultiNamespaceReferences( }; }); - const objectsToFindAliasesFor = objectsWithContext - .filter(({ spaces }) => spaces.length !== 0) - .map(({ type, id }) => ({ type, id })); + const foundObjects = objectsWithContext.filter(({ spaces }) => spaces.length !== 0); // Any objects that have a non-empty `spaces` field are "found" + const objectsToFindAliasesFor = foundObjects.map(({ type, id }) => ({ type, id })); const aliasesMap = await findLegacyUrlAliases( createPointInTimeFinder, objectsToFindAliasesFor, ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE ); - const objectOriginsToSearchFor = objectsWithContext - .filter(({ spaces }) => spaces.length !== 0) - .map(({ type, id, originId }) => ({ type, origin: originId || id })); + const objectOriginsToSearchFor = foundObjects.map(({ type, id, originId }) => ({ + type, + origin: originId || id, + })); const originsMap = await findSharedOriginObjects( createPointInTimeFinder, objectOriginsToSearchFor, @@ -118,8 +123,12 @@ export async function collectMultiNamespaceReferences( return { ...obj, spacesWithMatchingAliases, spacesWithMatchingOrigins }; }); + // Now that we have *all* information for the object graph, if the Security extension is enabled, we can: check/enforce authorization, + // write audit events, filter the object graph, and redact spaces from the objects. + const filteredAndRedactedResults = await optionallyUseSecurity(results, params); + return { - objects: results, + objects: filteredAndRedactedResults, }; } @@ -211,3 +220,196 @@ async function getObjectsAndReferences({ return { objectMap, inboundReferencesMap }; } + +/** + * Checks/enforces authorization, writes audit events, filters the object graph, and redacts spaces from the share_to_space/bulk_get + * response. In other SavedObjectsRepository functions we do this before decrypting attributes. However, because of the + * share_to_space/bulk_get response logic involved in deciding between the exact match or alias match, it's cleaner to do authorization, + * auditing, filtering, and redaction all afterwards. + */ +async function optionallyUseSecurity( + objectsWithContext: SavedObjectReferenceWithContext[], + params: CollectMultiNamespaceReferencesParams +) { + const { securityExtension, objects, options = {} } = params; + const { purpose, namespace } = options; + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + if (!securityExtension) { + return objectsWithContext; + } + + // Check authorization based on all *found* object types / spaces + const typesToAuthorize = new Set(); + const spacesToAuthorize = new Set([namespaceString]); + const addSpacesToAuthorize = (spaces: string[] = []) => { + for (const space of spaces) spacesToAuthorize.add(space); + }; + for (const obj of objectsWithContext) { + typesToAuthorize.add(obj.type); + addSpacesToAuthorize(obj.spaces); + addSpacesToAuthorize(obj.spacesWithMatchingAliases); + addSpacesToAuthorize(obj.spacesWithMatchingOrigins); + } + const action = + purpose === 'updateObjectsSpaces' ? ('share_to_space' as const) : ('bulk_get' as const); + const { typeMap } = await securityExtension.checkAuthorization({ + types: typesToAuthorize, + spaces: spacesToAuthorize, + actions: new Set([action]), + }); + + // Enforce authorization based on all *requested* object types and the current space + const typesAndSpaces = objects.reduce( + (acc, { type }) => (acc.has(type) ? acc : acc.set(type, new Set([namespaceString]))), // Always enforce authZ for the active space + new Map>() + ); + securityExtension!.enforceAuthorization({ + typesAndSpaces, + action, + typeMap, + auditCallback: (error) => { + if (!error) return; // We will audit success results below, after redaction + for (const { type, id } of objects) { + securityExtension!.addAuditEvent({ + action: AuditAction.COLLECT_MULTINAMESPACE_REFERENCES, + savedObject: { type, id }, + error, + }); + } + }, + }); + + // Now, filter/redact the results. Most SOR functions just redact the `namespaces` field from each returned object. However, this function + // will actually filter the returned object graph itself. + // This is done in two steps: (1) objects which the user can't access *in this space* are filtered from the graph, and the + // graph is rearranged to avoid leaking information. (2) any spaces that the user can't access are redacted from each individual object. + // After we finish filtering, we can write audit events for each object that is going to be returned to the user. + const requestedObjectsSet = objects.reduce( + (acc, { type, id }) => acc.add(`${type}:${id}`), + new Set() + ); + const retrievedObjectsSet = objectsWithContext.reduce( + (acc, { type, id }) => acc.add(`${type}:${id}`), + new Set() + ); + const traversedObjects = new Set(); + const filteredObjectsMap = new Map(); + const getIsAuthorizedForInboundReference = (inbound: { type: string; id: string }) => { + const found = filteredObjectsMap.get(`${inbound.type}:${inbound.id}`); + return found && !found.isMissing; // If true, this object can be linked back to one of the requested objects + }; + let objectsToProcess = [...objectsWithContext]; + while (objectsToProcess.length > 0) { + const obj = objectsToProcess.shift()!; + const { type, id, spaces, inboundReferences } = obj; + const objKey = `${type}:${id}`; + traversedObjects.add(objKey); + // Is the user authorized to access this object in this space? + let isAuthorizedForObject = true; + try { + securityExtension.enforceAuthorization({ + typesAndSpaces: new Map([[type, new Set([namespaceString])]]), + action, + typeMap, + }); + } catch (err) { + isAuthorizedForObject = false; + } + // Redact the inbound references so we don't leak any info about other objects that the user is not authorized to access + const redactedInboundReferences = inboundReferences.filter((inbound) => { + if (inbound.type === type && inbound.id === id) { + // circular reference, don't redact it + return true; + } + return getIsAuthorizedForInboundReference(inbound); + }); + // If the user is not authorized to access at least one inbound reference of this object, then we should omit this object. + const isAuthorizedForGraph = + requestedObjectsSet.has(objKey) || // If true, this is one of the requested objects, and we checked authorization above + redactedInboundReferences.some(getIsAuthorizedForInboundReference); + + if (isAuthorizedForObject && isAuthorizedForGraph) { + if (spaces.length) { + // Only generate success audit records for "non-empty results" with 1+ spaces + // ("empty result" means the object was a non-multi-namespace type, or hidden type, or not found) + securityExtension.addAuditEvent({ + action: AuditAction.COLLECT_MULTINAMESPACE_REFERENCES, + savedObject: { type, id }, + }); + } + filteredObjectsMap.set(objKey, obj); + } else if (!isAuthorizedForObject && isAuthorizedForGraph) { + filteredObjectsMap.set(objKey, { ...obj, spaces: [], isMissing: true }); + } else if (isAuthorizedForObject && !isAuthorizedForGraph) { + const hasUntraversedInboundReferences = inboundReferences.some( + (ref) => + !traversedObjects.has(`${ref.type}:${ref.id}`) && + retrievedObjectsSet.has(`${ref.type}:${ref.id}`) + ); + + if (hasUntraversedInboundReferences) { + // this object has inbound reference(s) that we haven't traversed yet; bump it to the back of the list + objectsToProcess = [...objectsToProcess, obj]; + } else { + // There should never be a missing inbound reference. + // If there is, then something has gone terribly wrong. + const missingInboundReference = inboundReferences.find( + (ref) => + !traversedObjects.has(`${ref.type}:${ref.id}`) && + !retrievedObjectsSet.has(`${ref.type}:${ref.id}`) + ); + + if (missingInboundReference) { + throw new Error( + `Unexpected inbound reference to "${missingInboundReference.type}:${missingInboundReference.id}"` + ); + } + } + } + } + + const filteredAndRedactedObjects = [ + ...filteredObjectsMap.values(), + ].map((obj) => { + const { + type, + id, + spaces, + spacesWithMatchingAliases, + spacesWithMatchingOrigins, + inboundReferences, + } = obj; + // Redact the inbound references so we don't leak any info about other objects that the user is not authorized to access + const redactedInboundReferences = inboundReferences.filter((inbound) => { + if (inbound.type === type && inbound.id === id) { + // circular reference, don't redact it + return true; + } + return getIsAuthorizedForInboundReference(inbound); + }); + + /** Simple wrapper for the `redactNamespaces` function that expects a saved object in its params. */ + const getRedactedSpaces = (spacesArray: string[] | undefined) => { + if (!spacesArray) return; + const savedObject = { type, namespaces: spacesArray } as SavedObject; // Other SavedObject attributes aren't required + const result = securityExtension.redactNamespaces({ savedObject, typeMap }); + return result.namespaces; + }; + const redactedSpaces = getRedactedSpaces(spaces)!; + const redactedSpacesWithMatchingAliases = getRedactedSpaces(spacesWithMatchingAliases); + const redactedSpacesWithMatchingOrigins = getRedactedSpaces(spacesWithMatchingOrigins); + return { + ...obj, + spaces: redactedSpaces, + ...(redactedSpacesWithMatchingAliases && { + spacesWithMatchingAliases: redactedSpacesWithMatchingAliases, + }), + ...(redactedSpacesWithMatchingOrigins && { + spacesWithMatchingOrigins: redactedSpacesWithMatchingOrigins, + }), + inboundReferences: redactedInboundReferences, + }; + }); + + return filteredAndRedactedObjects; +} diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.test.ts index 760a33689dbfc..44c81bc6eb49f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.test.ts @@ -6,10 +6,11 @@ * Side Public License, v 1. */ -import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; -import type { CreatePointInTimeFinderFn, PointInTimeFinder } from './point_in_time_finder'; +import { DeeplyMockedKeys } from '@kbn/utility-types-jest'; +import { CreatePointInTimeFinderFn, PointInTimeFinder } from './point_in_time_finder'; import { savedObjectsPointInTimeFinderMock } from '../mocks/point_in_time_finder.mock'; import { findSharedOriginObjects } from './find_shared_origin_objects'; +import { SavedObjectsPointInTimeFinderClient } from '@kbn/core-saved-objects-api-server'; interface MockFindResultParams { type: string; @@ -19,16 +20,13 @@ interface MockFindResultParams { } describe('findSharedOriginObjects', () => { - let savedObjectsMock: ReturnType; + let pitFinderClientMock: jest.Mocked; let pointInTimeFinder: DeeplyMockedKeys; let createPointInTimeFinder: jest.MockedFunction; beforeEach(() => { - savedObjectsMock = savedObjectsPointInTimeFinderMock.createClient(); - savedObjectsMock.openPointInTimeForType.mockResolvedValueOnce({ - id: 'abc123', - }); - savedObjectsMock.find.mockResolvedValue({ + pitFinderClientMock = savedObjectsPointInTimeFinderMock.createClient(); + pitFinderClientMock.find.mockResolvedValue({ pit_id: 'foo', saved_objects: [], // the rest of these fields don't matter but are included for type safety @@ -36,12 +34,14 @@ describe('findSharedOriginObjects', () => { page: 1, per_page: 100, }); - pointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ savedObjectsMock })(); // PIT finder mock uses the actual implementation, but it doesn't need to be created with real params because the SOR is mocked too + pointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ + savedObjectsMock: pitFinderClientMock, + })(); // PIT finder mock uses the actual implementation, but it doesn't need to be created with real params because the SOR is mocked too createPointInTimeFinder = jest.fn().mockReturnValue(pointInTimeFinder); }); function mockFindResults(...results: MockFindResultParams[]) { - savedObjectsMock.find.mockResolvedValueOnce({ + pitFinderClientMock.find.mockResolvedValueOnce({ pit_id: 'foo', saved_objects: results.map(({ type, id, originId, namespaces }) => ({ type, @@ -79,21 +79,23 @@ describe('findSharedOriginObjects', () => { const result = await findSharedOriginObjects(createPointInTimeFinder, objects); expect(createPointInTimeFinder).toHaveBeenCalledTimes(1); expect(createPointInTimeFinder).toHaveBeenCalledWith( - expect.objectContaining({ type: ['type-1', 'type-2', 'type-3', 'type-4'] }) // filter assertions are below + expect.objectContaining({ type: ['type-1', 'type-2', 'type-3', 'type-4'] }), // filter assertions are below + undefined, + { disableExtensions: true } ); const kueryFilterArgs = createPointInTimeFinder.mock.calls[0][0].filter.arguments; expect(kueryFilterArgs).toHaveLength(8); // 2 for each object [obj1, obj2, obj3].forEach(({ type, origin }, i) => { expect(kueryFilterArgs[i * 2].arguments).toEqual( expect.arrayContaining([ - { type: 'literal', value: `${type}.id`, isQuoted: false }, - { type: 'literal', value: `${type}:${origin}`, isQuoted: false }, + { isQuoted: false, type: 'literal', value: `${type}.id` }, + { isQuoted: false, type: 'literal', value: `${type}:${origin}` }, ]) ); expect(kueryFilterArgs[i * 2 + 1].arguments).toEqual( expect.arrayContaining([ - { type: 'literal', value: `${type}.originId`, isQuoted: false }, - { type: 'literal', value: origin, isQuoted: false }, + { isQuoted: false, type: 'literal', value: `${type}.originId` }, + { isQuoted: false, type: 'literal', value: origin }, ]) ); }); @@ -119,7 +121,11 @@ describe('findSharedOriginObjects', () => { const objects = [obj1, obj2, obj3]; await findSharedOriginObjects(createPointInTimeFinder, objects, 999); expect(createPointInTimeFinder).toHaveBeenCalledTimes(1); - expect(createPointInTimeFinder).toHaveBeenCalledWith(expect.objectContaining({ perPage: 999 })); + expect(createPointInTimeFinder).toHaveBeenCalledWith( + expect.objectContaining({ perPage: 999 }), + undefined, + { disableExtensions: true } + ); }); it('does not create a PointInTimeFinder if no objects are passed in', async () => { @@ -128,7 +134,7 @@ describe('findSharedOriginObjects', () => { }); it('handles PointInTimeFinder.find errors', async () => { - savedObjectsMock.find.mockRejectedValue(new Error('Oh no!')); + pitFinderClientMock.find.mockRejectedValue(new Error('Oh no!')); const objects = [obj1, obj2, obj3]; await expect(() => findSharedOriginObjects(createPointInTimeFinder, objects)).rejects.toThrow( diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.ts index 578cb2fda238d..a489e4afa91c3 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.ts @@ -34,13 +34,17 @@ export async function findSharedOriginObjects( const uniqueObjectTypes = objects.reduce((acc, { type }) => acc.add(type), new Set()); const filter = createAliasKueryFilter(objects); - const finder = createPointInTimeFinder({ - type: [...uniqueObjectTypes], - perPage, - filter, - fields: ['not-a-field'], // Specify a non-existent field to avoid fetching all type-level fields (we only care about root-level fields) - namespaces: [ALL_NAMESPACES_STRING], // We need to search across all spaces to have accurate results - }); + const finder = createPointInTimeFinder( + { + type: [...uniqueObjectTypes], + perPage, + filter, + fields: ['not-a-field'], // Specify a non-existent field to avoid fetching all type-level fields (we only care about root-level fields) + namespaces: [ALL_NAMESPACES_STRING], // We need to search across all spaces to have accurate results + }, + undefined, + { disableExtensions: true } + ); // NOTE: this objectsMap is only used internally (not in an API that is documented for public consumption), and it contains the minimal // amount of information to satisfy our UI needs today. We will need to change this in the future when we implement merging in #130311. const objectsMap = new Map>(); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.test.ts index 119b3ac85b698..2b2c2986aa674 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.test.ts @@ -24,12 +24,31 @@ import { LEGACY_URL_ALIAS_TYPE, } from '@kbn/core-saved-objects-base-server-internal'; import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; -import { internalBulkResolve, InternalBulkResolveParams } from './internal_bulk_resolve'; +import { internalBulkResolve, type InternalBulkResolveParams } from './internal_bulk_resolve'; import { normalizeNamespace } from './internal_utils'; +import { + AuditAction, + type ISavedObjectsEncryptionExtension, + type ISavedObjectsSecurityExtension, + type ISavedObjectTypeRegistry, +} from '@kbn/core-saved-objects-server'; +import { + authMap, + enforceError, + typeMapsAreEqual, + setsAreEqual, + setupCheckAuthorized, + setupCheckUnauthorized, + setupEnforceFailure, + setupEnforceSuccess, + setupRedactPassthrough, +} from '../test_helpers/repository.test.common'; +import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock'; const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 }; const OBJ_TYPE = 'obj-type'; const UNSUPPORTED_TYPE = 'unsupported-type'; +const ENCRYPTED_TYPE = 'encrypted-type'; beforeEach(() => { mockGetSavedObjectFromSource.mockReset(); @@ -46,23 +65,30 @@ describe('internalBulkResolve', () => { let client: ReturnType; let serializer: SavedObjectsSerializer; let incrementCounterInternal: jest.Mock; + let registry: jest.Mocked; /** Sets up the type registry, saved objects client, etc. and return the full parameters object to be passed to `internalBulkResolve` */ function setup( objects: SavedObjectsBulkResolveObject[], - options: SavedObjectsBaseOptions = {} + options: SavedObjectsBaseOptions = {}, + extensions?: { + encryptionExt?: ISavedObjectsEncryptionExtension; + securityExt?: ISavedObjectsSecurityExtension; + } ): InternalBulkResolveParams { - const registry = typeRegistryMock.create(); + registry = typeRegistryMock.create(); client = elasticsearchClientMock.createElasticsearchClient(); serializer = new SavedObjectsSerializer(registry); incrementCounterInternal = jest.fn().mockRejectedValue(new Error('increment error')); // mock error to implicitly test that it is caught and swallowed return { - registry: typeRegistryMock.create(), // doesn't need additional mocks for this test suite - allowedTypes: [OBJ_TYPE], + registry, + allowedTypes: [OBJ_TYPE, ENCRYPTED_TYPE], client, serializer, getIndexForType: (type: string) => `index-for-${type}`, incrementCounterInternal, + encryptionExtension: extensions?.encryptionExt, + securityExtension: extensions?.securityExt, objects, options, }; @@ -343,4 +369,231 @@ describe('internalBulkResolve', () => { ]); }); } + + describe('with encryption extension', () => { + const namespace = 'foo'; + + const attributes = { + attrNotSoSecret: '*not-so-secret*', + attrOne: 'one', + attrSecret: '*secret*', + attrThree: 'three', + title: 'Testing', + }; + + beforeEach(() => { + mockGetSavedObjectFromSource.mockImplementation((_registry, type, id) => { + return { + id, + type, + namespaces: [namespace], + attributes, + references: [], + } as SavedObject; + }); + }); + + it('only attempts to decrypt and strip attributes for types that are encryptable', async () => { + const objects = [ + { type: OBJ_TYPE, id: '11' }, // non encryptable type + { type: ENCRYPTED_TYPE, id: '12' }, // encryptable type + ]; + const mockEncryptionExt = savedObjectsExtensionsMock.createEncryptionExtension(); + const params = setup(objects, { namespace }, { encryptionExt: mockEncryptionExt }); + mockBulkResults( + // No alias matches + { found: false }, + { found: false } + ); + mockMgetResults( + // exact matches + { found: true }, + { found: true } + ); + + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true); + + await internalBulkResolve(params); + + expect(mockEncryptionExt.isEncryptableType).toBeCalledTimes(2); + expect(mockEncryptionExt.isEncryptableType).toBeCalledWith(OBJ_TYPE); + expect(mockEncryptionExt.isEncryptableType).toBeCalledWith(ENCRYPTED_TYPE); + + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledTimes(1); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledWith( + expect.objectContaining({ type: ENCRYPTED_TYPE, id: '12', attributes }) + ); + }); + }); + + describe('with security extension', () => { + const namespace = 'foo'; + const objects = [ + { type: OBJ_TYPE, id: '13' }, + { type: OBJ_TYPE, id: '14' }, + ]; + let mockSecurityExt: jest.Mocked; + let params: InternalBulkResolveParams; + + beforeEach(() => { + mockGetSavedObjectFromSource.mockReset(); + mockGetSavedObjectFromSource.mockImplementation((_registry, type, id) => { + return { + id, + type, + namespaces: [namespace], + attributes: {}, + references: [], + } as SavedObject; + }); + + mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension(); + params = setup(objects, { namespace }, { securityExt: mockSecurityExt }); + + mockBulkResults( + // No alias matches + { found: false }, + { found: false } + ); + mockMgetResults( + // exact matches + { found: true }, + { found: true } + ); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect(internalBulkResolve(params)).rejects.toThrow(enforceError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const result = await internalBulkResolve(params); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + + const bulkIds = objects.map((obj) => obj.id); + const expectedNamespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + expectBulkArgs(expectedNamespaceString, bulkIds); + const mgetIds = bulkIds; + expectMgetArgs(namespace, mgetIds); + expect(result.resolved_objects).toEqual([ + expect.objectContaining({ + outcome: 'exactMatch', + saved_object: expect.objectContaining({ id: objects[0].id }), + }), + expect.objectContaining({ + outcome: 'exactMatch', + saved_object: expect.objectContaining({ id: objects[1].id }), + }), + ]); + }); + + test(`calls checkAuthorization with type, actions, namespace, and object namespaces`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + await internalBulkResolve(params); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['bulk_get']); + const expectedSpaces = new Set([namespace]); + const expectedTypes = new Set([objects[0].type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with action, type map, and auth map`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + await internalBulkResolve(params); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'bulk_get', + }) + ); + + const expectedTypesAndSpaces = new Map([[objects[0].type, new Set([namespace])]]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + + test(`calls redactNamespaces with authorization map`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + await internalBulkResolve(params); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj, i) => { + const { savedObject, typeMap } = mockSecurityExt.redactNamespaces.mock.calls[i][0]; + expect(savedObject).toEqual( + expect.objectContaining({ + type: obj.type, + id: obj.id, + namespaces: [namespace], + }) + ); + expect(typeMap).toBe(authMap); + }); + }); + + test(`adds audit event per object when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + await internalBulkResolve(params); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.RESOLVE, + savedObject: { type: obj.type, id: obj.id }, + error: undefined, + }); + }); + }); + + test(`adds audit event per object when not successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect(internalBulkResolve(params)).rejects.toThrow(enforceError); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.RESOLVE, + savedObject: { type: obj.type, id: obj.id }, + error: enforceError, + }); + }); + }); + }); }); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.ts index 28e22d6e2634c..634d49c83732f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.ts @@ -17,12 +17,16 @@ import type { SavedObjectsIncrementCounterField, SavedObjectsIncrementCounterOptions, } from '@kbn/core-saved-objects-api-server'; -import type { - ISavedObjectTypeRegistry, - SavedObjectsRawDocSource, +import { + AuditAction, + type ISavedObjectsEncryptionExtension, + type ISavedObjectsSecurityExtension, + type ISavedObjectTypeRegistry, + type SavedObjectsRawDocSource, } from '@kbn/core-saved-objects-server'; import { SavedObjectsErrorHelpers, + SavedObjectsUtils, type DecoratedError, } from '@kbn/core-saved-objects-utils-server'; import { @@ -35,18 +39,21 @@ import { CORE_USAGE_STATS_TYPE, REPOSITORY_RESOLVE_OUTCOME_STATS, } from '@kbn/core-usage-data-base-server-internal'; +import pMap from 'p-map'; import { getCurrentTime, getSavedObjectFromSource, normalizeNamespace, rawDocExistsInNamespace, - Either, - Right, + type Either, + type Right, isLeft, isRight, } from './internal_utils'; import type { RepositoryEsClient } from './repository_es_client'; +const MAX_CONCURRENT_RESOLVE = 10; + /** * Parameters for the internal bulkResolve function. * @@ -64,6 +71,8 @@ export interface InternalBulkResolveParams { counterFields: Array, options?: SavedObjectsIncrementCounterOptions ) => Promise>; + encryptionExtension: ISavedObjectsEncryptionExtension | undefined; + securityExtension: ISavedObjectsSecurityExtension | undefined; objects: SavedObjectsBulkResolveObject[]; options?: SavedObjectsBaseOptions; } @@ -88,6 +97,13 @@ export interface InternalBulkResolveError { error: DecoratedError; } +/** Type guard used in the repository. */ +export function isBulkResolveError( + result: SavedObjectsResolveResponse | InternalBulkResolveError +): result is InternalBulkResolveError { + return !!(result as InternalBulkResolveError).error; +} + type AliasInfo = Pick; export async function internalBulkResolve( @@ -100,6 +116,8 @@ export async function internalBulkResolve( serializer, getIndexForType, incrementCounterInternal, + encryptionExtension, + securityExtension, objects, options = {}, } = params; @@ -166,70 +184,85 @@ export async function internalBulkResolve( let getResponseIndex = 0; let aliasInfoIndex = 0; - const resolveCounter = new ResolveCounter(); - const resolvedObjects = allObjects.map | InternalBulkResolveError>( - (either) => { - if (isLeft(either)) { - return either.value; - } - const exactMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++]; - let aliasMatchDoc: MgetResponseItem | undefined; - const aliasInfo = aliasInfoArray[aliasInfoIndex++]; - if (aliasInfo !== undefined) { - aliasMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++]; - } - const foundExactMatch = - // @ts-expect-error MultiGetHit._source is optional - exactMatchDoc.found && rawDocExistsInNamespace(registry, exactMatchDoc, namespace); - const foundAliasMatch = - // @ts-expect-error MultiGetHit._source is optional - aliasMatchDoc?.found && rawDocExistsInNamespace(registry, aliasMatchDoc, namespace); - - const { type, id } = either.value; - let result: SavedObjectsResolveResponse | null = null; - if (foundExactMatch && foundAliasMatch) { - result = { - // @ts-expect-error MultiGetHit._source is optional - saved_object: getSavedObjectFromSource(registry, type, id, exactMatchDoc), - outcome: 'conflict', - alias_target_id: aliasInfo!.targetId, - alias_purpose: aliasInfo!.purpose, - }; - resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT); - } else if (foundExactMatch) { - result = { - // @ts-expect-error MultiGetHit._source is optional - saved_object: getSavedObjectFromSource(registry, type, id, exactMatchDoc), - outcome: 'exactMatch', - }; - resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); - } else if (foundAliasMatch) { - result = { - saved_object: getSavedObjectFromSource( - registry, - type, - aliasInfo!.targetId, - // @ts-expect-error MultiGetHit._source is optional - aliasMatchDoc! - ), - outcome: 'aliasMatch', - alias_target_id: aliasInfo!.targetId, - alias_purpose: aliasInfo!.purpose, - }; - resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH); - } - if (result !== null) { - return result; - } - resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); - return { - type, - id, - error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id), + // Helper function for the map block below + async function getSavedObject( + objectType: string, + objectId: string, + doc: MgetResponseItem + ) { + // Encryption + // @ts-expect-error MultiGetHit._source is optional + const object = getSavedObjectFromSource(registry, objectType, objectId, doc); + if (!encryptionExtension?.isEncryptableType(object.type)) { + return object; + } + return encryptionExtension.decryptOrStripResponseAttributes(object); + } + + // map function for pMap below + const mapper = async ( + either: Either + ) => { + if (isLeft(either)) { + return either.value; + } + const exactMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++]; + let aliasMatchDoc: MgetResponseItem | undefined; + const aliasInfo = aliasInfoArray[aliasInfoIndex++]; + if (aliasInfo !== undefined) { + aliasMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++]; + } + const foundExactMatch = + // @ts-expect-error MultiGetHit._source is optional + exactMatchDoc.found && rawDocExistsInNamespace(registry, exactMatchDoc, namespace); + const foundAliasMatch = + // @ts-expect-error MultiGetHit._source is optional + aliasMatchDoc?.found && rawDocExistsInNamespace(registry, aliasMatchDoc, namespace); + + const { type, id } = either.value; + let result: SavedObjectsResolveResponse | null = null; + + if (foundExactMatch && foundAliasMatch) { + result = { + saved_object: await getSavedObject(type, id, exactMatchDoc!), + outcome: 'conflict', + alias_target_id: aliasInfo!.targetId, + alias_purpose: aliasInfo!.purpose, }; + resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT); + } else if (foundExactMatch) { + result = { + saved_object: await getSavedObject(type, id, exactMatchDoc!), + outcome: 'exactMatch', + }; + resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); + } else if (foundAliasMatch) { + result = { + saved_object: await getSavedObject(type, aliasInfo!.targetId, aliasMatchDoc!), + outcome: 'aliasMatch', + alias_target_id: aliasInfo!.targetId, + alias_purpose: aliasInfo!.purpose, + }; + resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH); } - ); + + if (result !== null) { + return result; + } + resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); + return { + type, + id, + error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id), + }; + }; + + const resolveCounter = new ResolveCounter(); + + const resolvedObjects = await pMap(allObjects, mapper, { + concurrency: MAX_CONCURRENT_RESOLVE, + }); incrementCounterInternal( CORE_USAGE_STATS_TYPE, @@ -238,7 +271,91 @@ export async function internalBulkResolve( { refresh: false } ).catch(() => {}); // if the call fails for some reason, intentionally swallow the error - return { resolved_objects: resolvedObjects }; + const redacted = await authorizeAuditAndRedact(resolvedObjects, securityExtension, namespace); + return { resolved_objects: redacted }; +} + +/** + * Checks authorization, writes audit events, and redacts namespaces from the bulkResolve response. In other SavedObjectsRepository + * functions we do this before decrypting attributes. However, because of the bulkResolve logic involved in deciding between the exact match + * or alias match, it's cleaner to do authorization, auditing, and redaction all afterwards. + */ +async function authorizeAuditAndRedact( + resolvedObjects: Array | InternalBulkResolveError>, + securityExtension: ISavedObjectsSecurityExtension | undefined, + namespace: string | undefined +) { + if (!securityExtension) { + return resolvedObjects; + } + + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + const typesAndSpaces = new Map>(); + const spacesToAuthorize = new Set(); + const auditableObjects: Array<{ type: string; id: string }> = []; + + for (const result of resolvedObjects) { + let auditableObject: { type: string; id: string } | undefined; + if (isBulkResolveError(result)) { + const { type, id, error } = result; + if (!SavedObjectsErrorHelpers.isBadRequestError(error)) { + // Only "not found" errors should show up as audit events (not "unsupported type" errors) + auditableObject = { type, id }; + } + } else { + const { type, id, namespaces = [] } = result.saved_object; + auditableObject = { type, id }; + for (const space of namespaces) { + spacesToAuthorize.add(space); + } + } + if (auditableObject) { + auditableObjects.push(auditableObject); + const spacesToEnforce = + typesAndSpaces.get(auditableObject.type) ?? new Set([namespaceString]); // Always enforce authZ for the active space + spacesToEnforce.add(namespaceString); + typesAndSpaces.set(auditableObject.type, spacesToEnforce); + spacesToAuthorize.add(namespaceString); + } + } + + if (typesAndSpaces.size === 0) { + // We only had "unsupported type" errors, there are no types to check privileges for, just return early + return resolvedObjects; + } + + const authorizationResult = await securityExtension.checkAuthorization({ + types: new Set(typesAndSpaces.keys()), + spaces: spacesToAuthorize, + actions: new Set(['bulk_get']), + }); + securityExtension.enforceAuthorization({ + typesAndSpaces, + action: 'bulk_get', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => { + for (const { type, id } of auditableObjects) { + securityExtension.addAuditEvent({ + action: AuditAction.RESOLVE, + savedObject: { type, id }, + error, + }); + } + }, + }); + + return resolvedObjects.map((result) => { + if (isBulkResolveError(result)) { + return result; + } + return { + ...result, + saved_object: securityExtension.redactNamespaces({ + typeMap: authorizationResult.typeMap, + savedObject: result.saved_object, + }), + }; + }); } /** Separates valid and invalid object types */ @@ -317,7 +434,6 @@ async function fetchAndUpdateAliases( return item.update?.get; }); } - class ResolveCounter { private record = new Map(); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts index 1af8bec312d77..41c4a3f726549 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/legacy_url_aliases/find_legacy_url_aliases.test.ts @@ -6,24 +6,25 @@ * Side Public License, v 1. */ -import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; +import { DeeplyMockedKeys } from '@kbn/utility-types-jest'; import { type LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE, } from '@kbn/core-saved-objects-base-server-internal'; -import type { CreatePointInTimeFinderFn, PointInTimeFinder } from '../point_in_time_finder'; +import { CreatePointInTimeFinderFn, PointInTimeFinder } from '../point_in_time_finder'; import { findLegacyUrlAliases } from './find_legacy_url_aliases'; import { savedObjectsPointInTimeFinderMock } from '../../mocks'; +import { SavedObjectsPointInTimeFinderClient } from '@kbn/core-saved-objects-api-server'; describe('findLegacyUrlAliases', () => { - let savedObjectsMock: ReturnType; + let pitFinderClientMock: jest.Mocked; let pointInTimeFinder: DeeplyMockedKeys; let createPointInTimeFinder: jest.MockedFunction; beforeEach(() => { - savedObjectsMock = savedObjectsPointInTimeFinderMock.createClient(); - savedObjectsMock.find.mockResolvedValue({ + pitFinderClientMock = savedObjectsPointInTimeFinderMock.createClient(); + pitFinderClientMock.find.mockResolvedValue({ pit_id: 'foo', saved_objects: [], // the rest of these fields don't matter but are included for type safety @@ -31,15 +32,14 @@ describe('findLegacyUrlAliases', () => { page: 1, per_page: 100, }); - savedObjectsMock.openPointInTimeForType.mockResolvedValueOnce({ - id: 'abc123', - }); - pointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ savedObjectsMock })(); // PIT finder mock uses the actual implementation, but it doesn't need to be created with real params because the SOR is mocked too + pointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ + savedObjectsMock: pitFinderClientMock, + })(); // PIT finder mock uses the actual implementation, but it doesn't need to be created with real params because the SOR is mocked too createPointInTimeFinder = jest.fn().mockReturnValue(pointInTimeFinder); }); function mockFindResults(...results: LegacyUrlAlias[]) { - savedObjectsMock.find.mockResolvedValueOnce({ + pitFinderClientMock.find.mockResolvedValueOnce({ pit_id: 'foo', saved_objects: results.map((attributes) => ({ id: 'doesnt-matter', @@ -74,10 +74,14 @@ describe('findLegacyUrlAliases', () => { const objects = [obj1, obj2, obj3]; const result = await findLegacyUrlAliases(createPointInTimeFinder, objects); expect(createPointInTimeFinder).toHaveBeenCalledTimes(1); - expect(createPointInTimeFinder).toHaveBeenCalledWith({ - type: LEGACY_URL_ALIAS_TYPE, - filter: expect.any(Object), // assertions are below - }); + expect(createPointInTimeFinder).toHaveBeenCalledWith( + { + type: LEGACY_URL_ALIAS_TYPE, + filter: expect.any(Object), // assertions are below + }, + undefined, + { disableExtensions: true } + ); const kueryFilterArgs = createPointInTimeFinder.mock.calls[0][0].filter.arguments; expect(kueryFilterArgs).toHaveLength(2); const typeAndIdFilters = kueryFilterArgs[1].arguments; @@ -86,10 +90,10 @@ describe('findLegacyUrlAliases', () => { const typeAndIdFilter = typeAndIdFilters[i].arguments; expect(typeAndIdFilter).toEqual([ expect.objectContaining({ - arguments: expect.arrayContaining([{ type: 'literal', value: type, isQuoted: false }]), + arguments: expect.arrayContaining([{ isQuoted: false, type: 'literal', value: type }]), }), expect.objectContaining({ - arguments: expect.arrayContaining([{ type: 'literal', value: id, isQuoted: false }]), + arguments: expect.arrayContaining([{ isQuoted: false, type: 'literal', value: id }]), }), ]); }); @@ -107,11 +111,15 @@ describe('findLegacyUrlAliases', () => { const objects = [obj1, obj2, obj3]; await findLegacyUrlAliases(createPointInTimeFinder, objects, 999); expect(createPointInTimeFinder).toHaveBeenCalledTimes(1); - expect(createPointInTimeFinder).toHaveBeenCalledWith({ - type: LEGACY_URL_ALIAS_TYPE, - perPage: 999, - filter: expect.any(Object), - }); + expect(createPointInTimeFinder).toHaveBeenCalledWith( + { + type: LEGACY_URL_ALIAS_TYPE, + perPage: 999, + filter: expect.any(Object), + }, + undefined, + { disableExtensions: true } + ); }); it('does not create a PointInTimeFinder if no objects are passed in', async () => { @@ -120,7 +128,7 @@ describe('findLegacyUrlAliases', () => { }); it('handles PointInTimeFinder.find errors', async () => { - savedObjectsMock.find.mockRejectedValue(new Error('Oh no!')); + pitFinderClientMock.find.mockRejectedValue(new Error('Oh no!')); const objects = [obj1, obj2, obj3]; await expect(() => findLegacyUrlAliases(createPointInTimeFinder, objects)).rejects.toThrow( diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/legacy_url_aliases/find_legacy_url_aliases.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/legacy_url_aliases/find_legacy_url_aliases.ts index be87a42de718c..62bf5a51d8893 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/legacy_url_aliases/find_legacy_url_aliases.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/legacy_url_aliases/find_legacy_url_aliases.ts @@ -34,11 +34,11 @@ export async function findLegacyUrlAliases( } const filter = createAliasKueryFilter(objects); - const finder = createPointInTimeFinder({ - type: LEGACY_URL_ALIAS_TYPE, - perPage, - filter, - }); + const finder = createPointInTimeFinder( + { type: LEGACY_URL_ALIAS_TYPE, perPage, filter }, + undefined, + { disableExtensions: true } + ); const aliasesMap = new Map>(); let error: Error | undefined; try { diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/point_in_time_finder.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/point_in_time_finder.test.ts index 2e31de3b267ef..fe8b8190ff840 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/point_in_time_finder.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/point_in_time_finder.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; import type { SavedObjectsFindResult, SavedObjectsCreatePointInTimeFinderOptions, @@ -69,9 +69,11 @@ describe('createPointInTimeFinder()', () => { namespaces: ['ns1', 'ns2'], }; + const internalOptions = {}; const finder = new PointInTimeFinder(findOptions, { logger, client: repository, + internalOptions, }); expect(repository.openPointInTimeForType).not.toHaveBeenCalled(); @@ -79,150 +81,158 @@ describe('createPointInTimeFinder()', () => { await finder.find().next(); expect(repository.openPointInTimeForType).toHaveBeenCalledTimes(1); - expect(repository.openPointInTimeForType).toHaveBeenCalledWith(findOptions.type, { - namespaces: findOptions.namespaces, - }); - }); - - test('throws if a PIT is already open', async () => { - repository.openPointInTimeForType.mockResolvedValueOnce({ - id: 'abc123', - }); - repository.find - .mockResolvedValueOnce({ - total: 2, - saved_objects: mockHits, - pit_id: 'abc123', - per_page: 1, - page: 0, - }) - .mockResolvedValueOnce({ - total: 2, - saved_objects: mockHits, - pit_id: 'abc123', - per_page: 1, - page: 1, - }); - - const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { - type: ['visualization'], - search: 'foo*', - perPage: 1, - }; - - const finder = new PointInTimeFinder(findOptions, { - logger, - client: repository, - }); - await finder.find().next(); - - expect(repository.find).toHaveBeenCalledTimes(1); - - expect(async () => { - await finder.find().next(); - }).rejects.toThrowErrorMatchingInlineSnapshot( - `"Point In Time has already been opened for this finder instance. Please call \`close()\` before calling \`find()\` again."` + expect(repository.openPointInTimeForType).toHaveBeenCalledWith( + findOptions.type, + { namespaces: findOptions.namespaces }, + internalOptions ); - expect(repository.find).toHaveBeenCalledTimes(1); }); + }); - test('works with a single page of results', async () => { - repository.openPointInTimeForType.mockResolvedValueOnce({ - id: 'abc123', - }); - repository.find.mockResolvedValueOnce({ + test('throws if a PIT is already open', async () => { + repository.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + repository.find + .mockResolvedValueOnce({ total: 2, saved_objects: mockHits, pit_id: 'abc123', - per_page: 2, + per_page: 1, page: 0, + }) + .mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 1, + page: 1, }); - const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { - type: ['visualization'], - search: 'foo*', - }; + const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; - const finder = new PointInTimeFinder(findOptions, { - logger, - client: repository, - }); - const hits: SavedObjectsFindResult[] = []; - for await (const result of finder.find()) { - hits.push(...result.saved_objects); - } + const finder = new PointInTimeFinder(findOptions, { + logger, + client: repository, + }); + await finder.find().next(); - expect(hits.length).toBe(2); - expect(repository.openPointInTimeForType).toHaveBeenCalledTimes(1); - expect(repository.closePointInTime).toHaveBeenCalledTimes(1); - expect(repository.find).toHaveBeenCalledTimes(1); - expect(repository.find).toHaveBeenCalledWith( - expect.objectContaining({ - pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), - sortField: 'updated_at', - sortOrder: 'desc', - type: ['visualization'], - }) - ); + expect(repository.find).toHaveBeenCalledTimes(1); + + expect(async () => { + await finder.find().next(); + }).rejects.toThrowErrorMatchingInlineSnapshot( + `"Point In Time has already been opened for this finder instance. Please call \`close()\` before calling \`find()\` again."` + ); + expect(repository.find).toHaveBeenCalledTimes(1); + }); + + test('works with a single page of results', async () => { + repository.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + repository.find.mockResolvedValueOnce({ + total: 2, + saved_objects: mockHits, + pit_id: 'abc123', + per_page: 2, + page: 0, }); - test('works with multiple pages of results', async () => { - repository.openPointInTimeForType.mockResolvedValueOnce({ - id: 'abc123', - }); - repository.find - .mockResolvedValueOnce({ - total: 2, - saved_objects: [mockHits[0]], - pit_id: 'abc123', - per_page: 1, - page: 0, - }) - .mockResolvedValueOnce({ - total: 2, - saved_objects: [mockHits[1]], - pit_id: 'abc123', - per_page: 1, - page: 0, - }); - repository.find.mockResolvedValueOnce({ + const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { + type: ['visualization'], + search: 'foo*', + }; + + const internalOptions = {}; + const finder = new PointInTimeFinder(findOptions, { + logger, + client: repository, + internalOptions, + }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + + expect(hits.length).toBe(2); + expect(repository.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(repository.closePointInTime).toHaveBeenCalledTimes(1); + expect(repository.find).toHaveBeenCalledTimes(1); + expect(repository.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['visualization'], + }), + internalOptions + ); + }); + + test('works with multiple pages of results', async () => { + repository.openPointInTimeForType.mockResolvedValueOnce({ + id: 'abc123', + }); + repository.find + .mockResolvedValueOnce({ total: 2, - saved_objects: [], + saved_objects: [mockHits[0]], + pit_id: 'abc123', per_page: 1, + page: 0, + }) + .mockResolvedValueOnce({ + total: 2, + saved_objects: [mockHits[1]], pit_id: 'abc123', + per_page: 1, page: 0, }); + repository.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [], + per_page: 1, + pit_id: 'abc123', + page: 0, + }); - const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { - type: ['visualization'], - search: 'foo*', - perPage: 1, - }; - - const finder = new PointInTimeFinder(findOptions, { - logger, - client: repository, - }); - const hits: SavedObjectsFindResult[] = []; - for await (const result of finder.find()) { - hits.push(...result.saved_objects); - } - - expect(hits.length).toBe(2); - expect(repository.openPointInTimeForType).toHaveBeenCalledTimes(1); - expect(repository.closePointInTime).toHaveBeenCalledTimes(1); - // called 3 times since we need a 3rd request to check if we - // are done paginating through results. - expect(repository.find).toHaveBeenCalledTimes(3); - expect(repository.find).toHaveBeenCalledWith( - expect.objectContaining({ - pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), - sortField: 'updated_at', - sortOrder: 'desc', - type: ['visualization'], - }) - ); + const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { + type: ['visualization'], + search: 'foo*', + perPage: 1, + }; + + const internalOptions = {}; + const finder = new PointInTimeFinder(findOptions, { + logger, + client: repository, + internalOptions, }); + const hits: SavedObjectsFindResult[] = []; + for await (const result of finder.find()) { + hits.push(...result.saved_objects); + } + + expect(hits.length).toBe(2); + expect(repository.openPointInTimeForType).toHaveBeenCalledTimes(1); + expect(repository.closePointInTime).toHaveBeenCalledTimes(1); + // called 3 times since we need a 3rd request to check if we + // are done paginating through results. + expect(repository.find).toHaveBeenCalledTimes(3); + expect(repository.find).toHaveBeenCalledWith( + expect.objectContaining({ + pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }), + sortField: 'updated_at', + sortOrder: 'desc', + type: ['visualization'], + }), + internalOptions + ); }); describe('#close', () => { @@ -244,9 +254,11 @@ describe('createPointInTimeFinder()', () => { perPage: 2, }; + const internalOptions = {}; const finder = new PointInTimeFinder(findOptions, { logger, client: repository, + internalOptions, }); const hits: SavedObjectsFindResult[] = []; for await (const result of finder.find()) { @@ -254,7 +266,7 @@ describe('createPointInTimeFinder()', () => { await finder.close(); } - expect(repository.closePointInTime).toHaveBeenCalledWith('test'); + expect(repository.closePointInTime).toHaveBeenCalledWith('test', undefined, internalOptions); }); test('causes generator to stop', async () => { @@ -315,9 +327,11 @@ describe('createPointInTimeFinder()', () => { perPage: 2, }; + const internalOptions = {}; const finder = new PointInTimeFinder(findOptions, { logger, client: repository, + internalOptions, }); const hits: SavedObjectsFindResult[] = []; try { @@ -328,7 +342,7 @@ describe('createPointInTimeFinder()', () => { // intentionally empty } - expect(repository.closePointInTime).toHaveBeenCalledWith('test'); + expect(repository.closePointInTime).toHaveBeenCalledWith('test', undefined, internalOptions); }); test('finder can be reused after closing', async () => { diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/point_in_time_finder.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/point_in_time_finder.ts index 2245201a634a0..7dffbbdaa356f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/point_in_time_finder.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/point_in_time_finder.ts @@ -14,6 +14,7 @@ import type { SavedObjectsCreatePointInTimeFinderOptions, ISavedObjectsPointInTimeFinder, SavedObjectsPointInTimeFinderClient, + SavedObjectsFindInternalOptions, } from '@kbn/core-saved-objects-api-server'; /** @@ -22,13 +23,16 @@ import type { export interface PointInTimeFinderDependencies extends SavedObjectsCreatePointInTimeFinderDependencies { logger: Logger; + internalOptions?: SavedObjectsFindInternalOptions; } /** * @internal */ export type CreatePointInTimeFinderFn = ( - findOptions: SavedObjectsCreatePointInTimeFinderOptions + findOptions: SavedObjectsCreatePointInTimeFinderOptions, + dependencies?: SavedObjectsCreatePointInTimeFinderDependencies, + internalOptions?: SavedObjectsFindInternalOptions ) => ISavedObjectsPointInTimeFinder; /** @@ -40,15 +44,17 @@ export class PointInTimeFinder readonly #log: Logger; readonly #client: SavedObjectsPointInTimeFinderClient; readonly #findOptions: SavedObjectsFindOptions; + readonly #internalOptions: SavedObjectsFindInternalOptions | undefined; #open: boolean = false; #pitId?: string; constructor( findOptions: SavedObjectsCreatePointInTimeFinderOptions, - { logger, client }: PointInTimeFinderDependencies + { logger, client, internalOptions }: PointInTimeFinderDependencies ) { this.#log = logger.get('point-in-time-finder'); this.#client = client; + this.#internalOptions = internalOptions; this.#findOptions = { // Default to 1000 items per page as a tradeoff between // speed and memory consumption. @@ -99,7 +105,7 @@ export class PointInTimeFinder try { if (this.#pitId) { this.#log.debug(`Closing PIT for types [${this.#findOptions.type}]`); - await this.#client.closePointInTime(this.#pitId); + await this.#client.closePointInTime(this.#pitId, undefined, this.#internalOptions); this.#pitId = undefined; } this.#open = false; @@ -111,9 +117,11 @@ export class PointInTimeFinder private async open() { try { - const { id } = await this.#client.openPointInTimeForType(this.#findOptions.type, { - namespaces: this.#findOptions.namespaces, - }); + const { id } = await this.#client.openPointInTimeForType( + this.#findOptions.type, + { namespaces: this.#findOptions.namespaces }, + this.#internalOptions + ); this.#pitId = id; this.#open = true; } catch (e) { @@ -137,16 +145,19 @@ export class PointInTimeFinder searchAfter?: estypes.Id[]; }) { try { - return await this.#client.find({ - // Sort fields are required to use searchAfter, so we set some defaults here - sortField: 'updated_at', - sortOrder: 'desc', - // Bump keep_alive by 2m on every new request to allow for the ES client - // to make multiple retries in the event of a network failure. - pit: id ? { id, keepAlive: '2m' } : undefined, - searchAfter, - ...findOptions, - }); + return await this.#client.find( + { + // Sort fields are required to use searchAfter, so we set some defaults here + sortField: 'updated_at', + sortOrder: 'desc', + // Bump keep_alive by 2m on every new request to allow for the ES client + // to make multiple retries in the event of a network failure. + pit: id ? { id, keepAlive: '2m' } : undefined, + searchAfter, + ...findOptions, + }, + this.#internalOptions + ); } catch (e) { if (id) { // Clean up PIT on any errors. diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.test.ts index d23d2cf5e804e..f2901e4b53187 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.test.ts @@ -23,8 +23,8 @@ import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; import type { CreatePointInTimeFinderFn } from './point_in_time_finder'; import { ALIAS_SEARCH_PER_PAGE, - PreflightCheckForCreateObject, - PreflightCheckForCreateParams, + type PreflightCheckForCreateObject, + type PreflightCheckForCreateParams, } from './preflight_check_for_create'; import { preflightCheckForCreate } from './preflight_check_for_create'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.ts index 1d1b4839635e8..1daa7b5f37ea4 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.ts @@ -24,7 +24,7 @@ import { type SavedObjectsSerializer, } from '@kbn/core-saved-objects-base-server-internal'; import { findLegacyUrlAliases } from './legacy_url_aliases'; -import { Either, rawDocExistsInNamespaces } from './internal_utils'; +import { type Either, rawDocExistsInNamespaces } from './internal_utils'; import { isLeft, isRight } from './internal_utils'; import type { CreatePointInTimeFinderFn } from './point_in_time_finder'; import type { RepositoryEsClient } from './repository_es_client'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.encryption_extension.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.encryption_extension.test.ts new file mode 100644 index 0000000000000..a297d6b5a2b7e --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.encryption_extension.test.ts @@ -0,0 +1,687 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + pointInTimeFinderMock, + mockGetCurrentTime, + mockPreflightCheckForCreate, + mockGetSearchDsl, +} from './repository.test.mock'; + +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +import { SavedObjectsRepository } from './repository'; +import { loggerMock } from '@kbn/logging-mocks'; + +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { kibanaMigratorMock } from '../mocks'; +import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal'; +import { + ISavedObjectsEncryptionExtension, + SavedObjectsRawDocSource, +} from '@kbn/core-saved-objects-server'; +import { + bulkCreateSuccess, + bulkGetSuccess, + bulkUpdateSuccess, + createDocumentMigrator, + createRegistry, + createSpySerializer, + ENCRYPTED_TYPE, + findSuccess, + getMockGetResponse, + mappings, + mockTimestamp, + mockTimestampFields, + mockVersion, + mockVersionProps, + MULTI_NAMESPACE_ENCRYPTED_TYPE, + TypeIdTuple, + updateSuccess, +} from '../test_helpers/repository.test.common'; +import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock'; + +// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository +// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. + +describe('SavedObjectsRepository Encryption Extension', () => { + let client: ReturnType; + let repository: SavedObjectsRepository; + let migrator: ReturnType; + let logger: ReturnType; + let serializer: jest.Mocked; + let mockEncryptionExt: jest.Mocked; + + const registry = createRegistry(); + const documentMigrator = createDocumentMigrator(registry); + + const namespace = 'foo-namespace'; + + const encryptedSO = { + id: 'encrypted-id', + type: ENCRYPTED_TYPE, + namespaces: [namespace], + attributes: { + attrNotSoSecret: '*not-so-secret*', + attrOne: 'one', + attrSecret: '*secret*', + attrThree: 'three', + title: 'Testing', + }, + references: [], + }; + const decryptedStrippedAttributes = { + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, + }; + const nonEncryptedSO = { + id: 'non-encrypted-id', + type: 'index-pattern', + namespaces: [namespace], + attributes: { title: 'Logstash' }, + references: [], + }; + + const instantiateRepository = () => { + const allTypes = registry.getAllTypes().map((type) => type.name); + const allowedTypes = [...new Set(allTypes.filter((type) => !registry.isHidden(type)))]; + + // @ts-expect-error must use the private constructor to use the mocked serializer + return new SavedObjectsRepository({ + index: '.kibana-test', + mappings, + client, + migrator, + typeRegistry: registry, + serializer, + allowedTypes, + logger, + extensions: { encryptionExtension: mockEncryptionExt }, + }); + }; + + beforeEach(() => { + pointInTimeFinderMock.mockClear(); + client = elasticsearchClientMock.createElasticsearchClient(); + migrator = kibanaMigratorMock.create(); + documentMigrator.prepareMigrations(); + migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate); + migrator.runMigrations = jest.fn().mockResolvedValue([{ status: 'skipped' }]); + logger = loggerMock.create(); + + // create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation + serializer = createSpySerializer(registry); + + // create a mock saved objects encryption extension + mockEncryptionExt = savedObjectsExtensionsMock.createEncryptionExtension(); + + mockGetCurrentTime.mockReturnValue(mockTimestamp); + mockGetSearchDsl.mockClear(); + + repository = instantiateRepository(); + }); + + describe('#get', () => { + it('does not attempt to decrypt or strip attributes if type is not encryptable', async () => { + const options = { namespace }; + + const response = getMockGetResponse(registry, { + type: nonEncryptedSO.type, + id: nonEncryptedSO.id, + namespace, + }); + + client.get.mockResponseOnce(response); + mockEncryptionExt.isEncryptableType.mockReturnValue(false); + const result = await repository.get(nonEncryptedSO.type, nonEncryptedSO.id, options); + expect(client.get).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).not.toBeCalled(); + expect(result).toEqual( + expect.objectContaining({ + type: nonEncryptedSO.type, + id: nonEncryptedSO.id, + namespaces: [namespace], + }) + ); + }); + + it('decrypts and strips attributes if type is encryptable', async () => { + const options = { namespace }; + + const response = getMockGetResponse(registry, { + type: encryptedSO.type, + id: encryptedSO.id, + namespace: options.namespace, + }); + client.get.mockResponseOnce(response); + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({ + ...encryptedSO, + ...decryptedStrippedAttributes, + }); + + const result = await repository.get(encryptedSO.type, encryptedSO.id, options); + expect(client.get).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + ...encryptedSO, + }), + undefined + ); + expect(result).toEqual( + expect.objectContaining({ + ...decryptedStrippedAttributes, + }) + ); + }); + }); + + describe('#create', () => { + beforeEach(() => { + mockPreflightCheckForCreate.mockReset(); + mockPreflightCheckForCreate.mockImplementation(({ objects }) => { + return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default + }); + client.create.mockResponseImplementation((params) => { + return { + body: { + _id: params.id, + ...mockVersionProps, + } as estypes.CreateResponse, + }; + }); + }); + + it('does not attempt to encrypt or decrypt if type is not encryptable', async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(false); + const result = await repository.create(nonEncryptedSO.type, nonEncryptedSO.attributes, { + namespace, + }); + expect(mockPreflightCheckForCreate).not.toHaveBeenCalled(); + expect(client.create).toHaveBeenCalled(); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(3); // getValidId, optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type); + expect(mockEncryptionExt.encryptAttributes).not.toHaveBeenCalled(); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).not.toBeCalled(); + expect(result).toEqual( + expect.objectContaining({ + type: nonEncryptedSO.type, + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + namespaces: [namespace], + }) + ); + }); + + it('encrypts attributes and strips them from response if type is encryptable', async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({ + ...encryptedSO, + ...decryptedStrippedAttributes, + }); + + const result = await repository.create(encryptedSO.type, encryptedSO.attributes, { + namespace, + }); + expect(mockPreflightCheckForCreate).not.toHaveBeenCalled(); + expect(client.create).toHaveBeenCalled(); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(3); // getValidId, optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith( + { + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + namespace, + type: ENCRYPTED_TYPE, + }, + encryptedSO.attributes + ); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + ...encryptedSO, + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + attributes: undefined, + }), + encryptedSO.attributes // original attributes + ); + expect(result).toEqual( + expect.objectContaining({ + ...decryptedStrippedAttributes, + }) + ); + }); + + it(`fails if non-UUID ID is specified for encrypted type`, async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({ + ...encryptedSO, + ...decryptedStrippedAttributes, + }); + + await expect( + repository.create(encryptedSO.type, encryptedSO.attributes, { + id: 'this-should-throw-an-error', + }) + ).rejects.toThrow( + 'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.: Bad Request' + ); + }); + + it(`allows a specified ID when overwriting an existing object`, async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({ + ...encryptedSO, + ...decryptedStrippedAttributes, + }); + + await expect( + repository.create(encryptedSO.type, encryptedSO.attributes, { + id: encryptedSO.id, + overwrite: true, + version: mockVersion, + }) + ).resolves.not.toThrowError(); + }); + + describe('namespace', () => { + const doTest = async (optNamespace: string, expectNamespaceInDescriptor: boolean) => { + const options = { overwrite: true, namespace: optNamespace }; + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + + await repository.create( + expectNamespaceInDescriptor ? ENCRYPTED_TYPE : MULTI_NAMESPACE_ENCRYPTED_TYPE, + encryptedSO.attributes, + options + ); + expect(mockPreflightCheckForCreate).not.toHaveBeenCalled(); + expect(client.index).toHaveBeenCalled(); // if overwrite is true, index will be called instead of create + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(3); // getValidId, optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith( + expectNamespaceInDescriptor ? ENCRYPTED_TYPE : MULTI_NAMESPACE_ENCRYPTED_TYPE + ); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith( + { + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + namespace: expectNamespaceInDescriptor ? namespace : undefined, + type: expectNamespaceInDescriptor ? ENCRYPTED_TYPE : MULTI_NAMESPACE_ENCRYPTED_TYPE, + }, + encryptedSO.attributes + ); + }; + + it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => { + await doTest(namespace, true); + }); + + it('does not use `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { + await doTest(namespace, false); + }); + }); + }); + + describe('#update', () => { + const attributes = { title: 'Testing' }; + + beforeEach(() => { + mockPreflightCheckForCreate.mockReset(); + mockPreflightCheckForCreate.mockImplementation(({ objects }) => { + return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default + }); + }); + + it('does not attempt to encrypt or decrypt if type is not encryptable', async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(false); + const result = await updateSuccess( + client, + repository, + registry, + nonEncryptedSO.type, + nonEncryptedSO.id, + attributes, + { + namespace, + } + ); + expect(client.update).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // (no upsert) optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type); + expect(mockEncryptionExt.encryptAttributes).not.toHaveBeenCalled(); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).not.toBeCalled(); + expect(result).toEqual( + expect.objectContaining({ + type: nonEncryptedSO.type, + id: nonEncryptedSO.id, + namespaces: [namespace], + }) + ); + }); + + it('encrypts attributes and strips them from response if type is encryptable', async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({ + ...encryptedSO, + ...decryptedStrippedAttributes, + }); + const result = await updateSuccess( + client, + repository, + registry, + encryptedSO.type, + encryptedSO.id, + encryptedSO.attributes, + { + namespace, + references: encryptedSO.references, + } + ); + expect(client.update).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // (no upsert) optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith( + { + id: encryptedSO.id, + namespace, + type: ENCRYPTED_TYPE, + }, + encryptedSO.attributes + ); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + ...encryptedSO, + }), + encryptedSO.attributes // original attributes + ); + expect(result).toEqual( + expect.objectContaining({ + ...decryptedStrippedAttributes, + }) + ); + }); + }); + + describe('#bulkGet', () => { + const _expectClientCallArgs = ( + objects: TypeIdTuple[], + { + _index = expect.any(String), + getId = () => expect.any(String), + }: { _index?: string; getId?: (type: string, id: string) => string } + ) => { + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + docs: objects.map(({ type, id }) => + expect.objectContaining({ + _index, + _id: getId(type, id), + }) + ), + }, + }), + expect.anything() + ); + }; + + it(`only attempts to decrypt and strip attributes for types that are encryptable`, async () => { + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true); + const getId = (type: string, id: string) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) + await bulkGetSuccess(client, repository, registry, [nonEncryptedSO, encryptedSO], { + namespace, + }); + _expectClientCallArgs([nonEncryptedSO, encryptedSO], { getId }); + expect(mockEncryptionExt.isEncryptableType).toBeCalledTimes(2); + expect(mockEncryptionExt.isEncryptableType).toBeCalledWith(nonEncryptedSO.type); + expect(mockEncryptionExt.isEncryptableType).toBeCalledWith(encryptedSO.type); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledTimes(1); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledWith( + expect.objectContaining({ ...encryptedSO }), + undefined + ); + }); + }); + + describe('#bulkCreate', () => { + it(`only attempts to encrypt and decrypt attributes for types that are encryptable`, async () => { + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); // getValidId + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true); + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); // optionallyEncryptAttributes + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true); + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); // optionallyDecryptAndRedactSingleResult + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true); + await bulkCreateSuccess(client, repository, [ + nonEncryptedSO, + { ...encryptedSO, id: undefined }, // Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID + ]); + expect(client.bulk).toHaveBeenCalledTimes(1); + + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(6); // x2 getValidId, optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith( + expect.objectContaining({ type: encryptedSO.type }), + encryptedSO.attributes + ); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledWith( + expect.objectContaining({ type: encryptedSO.type }), + encryptedSO.attributes + ); + }); + + it(`fails if non-UUID ID is specified for encrypted type`, async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + const result = await bulkCreateSuccess(client, repository, [ + encryptedSO, // Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID + ]); + expect(client.bulk).not.toHaveBeenCalled(); + expect(result.saved_objects).not.toBeUndefined(); + expect(result.saved_objects.length).toBe(1); + expect(result.saved_objects[0].error).not.toBeUndefined(); + expect(result.saved_objects[0].error).toMatchObject({ + statusCode: 400, + error: 'Bad Request', + message: + 'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.: Bad Request', + }); + }); + + it(`does not fail if ID is specified for not encrypted type`, async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(false); + const result = await bulkCreateSuccess(client, repository, [nonEncryptedSO]); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(result.saved_objects).not.toBeUndefined(); + expect(result.saved_objects.length).toBe(1); + expect(result.saved_objects[0].error).toBeUndefined(); + }); + + it(`allows a specified ID when overwriting an existing object`, async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({ + ...encryptedSO, + version: mockVersion, + ...decryptedStrippedAttributes, + }); + + const result = await bulkCreateSuccess( + client, + repository, + [{ ...encryptedSO, version: mockVersion }], + { + overwrite: true, + // version: mockVersion, // this doesn't work in bulk...looks like it checks the object itself? + } + ); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(result.saved_objects).not.toBeUndefined(); + expect(result.saved_objects.length).toBe(1); + expect(result.saved_objects[0].error).toBeUndefined(); + }); + }); + + describe('#bulkUpdate', () => { + it(`only attempts to encrypt and decrypt attributes for types that are encryptable`, async () => { + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); // optionallyEncryptAttributes + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true); + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); // optionallyDecryptAndRedactSingleResult + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true); + + await bulkUpdateSuccess(client, repository, registry, [nonEncryptedSO, encryptedSO]); + expect(client.bulk).toHaveBeenCalledTimes(1); + + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(4); // 2x optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type); + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith( + expect.objectContaining({ type: encryptedSO.type }), + encryptedSO.attributes + ); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledWith( + expect.objectContaining({ type: encryptedSO.type }), + encryptedSO.attributes + ); + }); + + it('does not use options `namespace` or object `namespace` to encrypt attributes if neither are specified', async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + + await bulkUpdateSuccess( + client, + repository, + registry, + [{ ...encryptedSO, namespace: undefined }], + { namespace: undefined } + ); + expect(client.bulk).toHaveBeenCalledTimes(1); + + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // 2x optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith( + { id: encryptedSO.id, type: encryptedSO.type, namespace: undefined }, + encryptedSO.attributes + ); + }); + + it('with a single-namespace type...uses options `namespace` to encrypt attributes if it is specified and object `namespace` is not', async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + const usedNamespace = 'options-namespace'; + + await bulkUpdateSuccess( + client, + repository, + registry, + [{ ...encryptedSO, namespace: undefined }], + { namespace: usedNamespace } + ); + expect(client.bulk).toHaveBeenCalledTimes(1); + + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // 2x optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith( + { id: encryptedSO.id, type: encryptedSO.type, namespace: usedNamespace }, + encryptedSO.attributes + ); + }); + + it('with a single-namespace type...uses object `namespace` to encrypt attributes if it is specified', async () => { + mockEncryptionExt.isEncryptableType.mockReturnValue(true); + const usedNamespace = 'object-namespace'; + + await bulkUpdateSuccess( + client, + repository, + registry, + [{ ...encryptedSO, namespace: usedNamespace }], + { namespace: undefined } + ); + expect(client.bulk).toHaveBeenCalledTimes(1); + + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // 2x optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult + expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith( + { id: encryptedSO.id, type: encryptedSO.type, namespace: usedNamespace }, + encryptedSO.attributes + ); + }); + }); + + describe('#find', () => { + const generateSearchResults = (space?: string) => { + return { + took: 1, + timed_out: false, + _shards: {} as any, + hits: { + total: 2, + hits: [ + { + _index: '.kibana', + _id: `${space ? `${space}:` : ''}${encryptedSO.type}:${encryptedSO.id}`, + _score: 1, + ...mockVersionProps, + _source: { + ...encryptedSO, + originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case + }, + }, + { + _index: '.kibana', + _id: `${space ? `${space}:` : ''}index-pattern:logstash-*`, + _score: 2, + ...mockVersionProps, + _source: { + namespace: space, + originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { + title: 'logstash-*', + timeFieldName: '@timestamp', + notExpandable: true, + }, + }, + }, + ], + }, + } as estypes.SearchResponse; + }; + + it(`only attempts to decrypt and strip attributes for types that are encryptable`, async () => { + mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true); + await findSuccess( + client, + repository, + { type: [encryptedSO.type, 'index-pattern'] }, + undefined, + generateSearchResults + ); + expect(client.search).toHaveBeenCalledTimes(1); + expect(mockEncryptionExt.isEncryptableType).toBeCalledTimes(2); + expect(mockEncryptionExt.isEncryptableType).toBeCalledWith(encryptedSO.type); + expect(mockEncryptionExt.isEncryptableType).toBeCalledWith('index-pattern'); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledTimes(1); + expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledWith( + expect.objectContaining({ type: encryptedSO.type, id: encryptedSO.id }), + undefined + ); + }); + }); +}); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.security_extension.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.security_extension.test.ts new file mode 100644 index 0000000000000..eea97945b4dda --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.security_extension.test.ts @@ -0,0 +1,2034 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + pointInTimeFinderMock, + mockGetCurrentTime, + mockGetSearchDsl, + mockDeleteLegacyUrlAliases, + mockPreflightCheckForCreate, +} from './repository.test.mock'; + +import { SavedObjectsRepository } from './repository'; +import { loggerMock } from '@kbn/logging-mocks'; +import { estypes } from '@elastic/elasticsearch'; +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { SavedObjectsBulkUpdateObject } from '@kbn/core-saved-objects-api-server'; +import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal'; +import { SavedObject } from '@kbn/core-saved-objects-common'; +import { + ISavedObjectsSecurityExtension, + AuditAction, + SavedObjectsRawDocSource, + AuthorizationTypeEntry, +} from '@kbn/core-saved-objects-server'; +import { kibanaMigratorMock } from '../mocks'; +import { + createRegistry, + createDocumentMigrator, + mappings, + createSpySerializer, + mockTimestamp, + checkAuthError, + getSuccess, + setupCheckUnauthorized, + setupEnforceFailure, + enforceError, + setupCheckAuthorized, + setupRedactPassthrough, + MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, + setsAreEqual, + typeMapsAreEqual, + authMap, + setupEnforceSuccess, + updateSuccess, + deleteSuccess, + removeReferencesToSuccess, + REMOVE_REFS_COUNT, + checkConflictsSuccess, + findSuccess, + mockTimestampFields, + mockVersion, + NAMESPACE_AGNOSTIC_TYPE, + setupCheckPartiallyAuthorized, + bulkGetSuccess, + expectBulkGetResult, + bulkCreateSuccess, + expectCreateResult, + MULTI_NAMESPACE_TYPE, + bulkUpdateSuccess, + expectUpdateResult, + bulkDeleteSuccess, + createBulkDeleteSuccessStatus, + namespaceMapsAreEqual, +} from '../test_helpers/repository.test.common'; +import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock'; + +// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository +// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. + +describe('SavedObjectsRepository Security Extension', () => { + let client: ReturnType; + let repository: SavedObjectsRepository; + let migrator: ReturnType; + let logger: ReturnType; + let serializer: jest.Mocked; + let mockSecurityExt: jest.Mocked; + + const registry = createRegistry(); + const documentMigrator = createDocumentMigrator(registry); + + const instantiateRepository = () => { + const allTypes = registry.getAllTypes().map((type) => type.name); + const allowedTypes = [...new Set(allTypes.filter((type) => !registry.isHidden(type)))]; + + // @ts-expect-error must use the private constructor to use the mocked serializer + return new SavedObjectsRepository({ + index: '.kibana-test', + mappings, + client, + migrator, + typeRegistry: registry, + serializer, + allowedTypes, + logger, + extensions: { securityExtension: mockSecurityExt }, + }); + }; + + const type = 'index-pattern'; + const id = 'logstash-*'; + const namespace = 'foo-namespace'; + const attributes = { attr1: 'one', attr2: 'two', attr3: 'three' }; + const multiNamespaceObjNamespaces = ['ns-1', 'ns-2', namespace]; + + beforeEach(() => { + pointInTimeFinderMock.mockClear(); + client = elasticsearchClientMock.createElasticsearchClient(); + migrator = kibanaMigratorMock.create(); + documentMigrator.prepareMigrations(); + migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate); + migrator.runMigrations = jest.fn().mockResolvedValue([{ status: 'skipped' }]); + logger = loggerMock.create(); + + // create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation + serializer = createSpySerializer(registry); + + // create a mock saved objects encryption extension + mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension(); + + mockGetCurrentTime.mockReturnValue(mockTimestamp); + mockGetSearchDsl.mockClear(); + + repository = instantiateRepository(); + }); + + afterEach(() => { + mockSecurityExt.checkAuthorization.mockClear(); + mockSecurityExt.redactNamespaces.mockClear(); + }); + + describe('#get', () => { + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect( + getSuccess(client, repository, registry, type, id, { namespace }) + ).rejects.toThrow(checkAuthError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + getSuccess(client, repository, registry, type, id, { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const result = await getSuccess(client, repository, registry, type, id, { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(1); + expect(result).toEqual(expect.objectContaining({ type, id, namespaces: [namespace] })); + }); + + test(`calls checkAuthorization with type, actions, and namespaces`, async () => { + await getSuccess( + client, + repository, + registry, + MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, + id, + { + namespace, + }, + undefined, + multiNamespaceObjNamespaces // all of the object's namespaces from preflight check are added to the auth check call + ); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['get']); + const expectedSpaces = new Set(multiNamespaceObjNamespaces); + const expectedTypes = new Set([MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with action, type map, and auth map`, async () => { + setupCheckAuthorized(mockSecurityExt); + + await getSuccess(client, repository, registry, type, id, { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'get', + }) + ); + + const expectedTypesAndSpaces = new Map([[type, new Set([namespace])]]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + + test(`calls redactNamespaces with authorization map`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + await getSuccess(client, repository, registry, type, id, { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledWith( + expect.objectContaining({ + typeMap: authMap, + savedObject: expect.objectContaining({ type, id, namespaces: [namespace] }), + }) + ); + }); + + test(`adds audit event when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + await getSuccess(client, repository, registry, type, id, { namespace }); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.GET, + savedObject: { type, id }, + }); + }); + + test(`adds audit event when not successful`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + getSuccess(client, repository, registry, type, id, { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.GET, + savedObject: { type, id }, + error: enforceError, + }); + }); + }); + + describe('#update', () => { + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect( + updateSuccess(client, repository, registry, type, id, attributes, { namespace }) + ).rejects.toThrow(checkAuthError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + updateSuccess(client, repository, registry, type, id, attributes, { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const result = await updateSuccess(client, repository, registry, type, id, attributes, { + namespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); + expect(result).toEqual( + expect.objectContaining({ id, type, attributes, namespaces: [namespace] }) + ); + }); + + test(`calls checkAuthorization with type, actions, and namespaces`, async () => { + await updateSuccess( + client, + repository, + registry, + MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, + id, + attributes, + { + namespace, + }, + {}, + multiNamespaceObjNamespaces // all of the object's namespaces from preflight check are added to the auth check call + ); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + + const expectedActions = new Set(['update']); + const expectedSpaces = new Set(multiNamespaceObjNamespaces); + const expectedTypes = new Set([MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with action, type map, and auth map`, async () => { + mockSecurityExt.checkAuthorization.mockResolvedValue({ + status: 'fully_authorized', + typeMap: authMap, + }); + + await updateSuccess( + client, + repository, + registry, + type, + id, + attributes, + { namespace }, + undefined, + multiNamespaceObjNamespaces + ); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'update', + }) + ); + + const expectedTypesAndSpaces = new Map([[type, new Set([namespace])]]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + + test(`calls redactNamespaces with authorization map`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + await updateSuccess(client, repository, registry, type, id, attributes, { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledWith( + expect.objectContaining({ + typeMap: authMap, + savedObject: expect.objectContaining({ type, id, namespaces: [namespace] }), + }) + ); + }); + + test(`adds audit event when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + await updateSuccess(client, repository, registry, type, id, attributes, { namespace }); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.UPDATE, + savedObject: { type, id }, + error: undefined, + outcome: 'unknown', + }); + }); + test(`adds audit event when not successful`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + updateSuccess(client, repository, registry, type, id, { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.UPDATE, + savedObject: { type, id }, + error: enforceError, + }); + }); + }); + + describe('#create', () => { + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect(repository.create(type, attributes, { namespace })).rejects.toThrow( + checkAuthError + ); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect(repository.create(type, attributes, { namespace })).rejects.toThrow( + enforceError + ); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const result = await repository.create(type, attributes, { + namespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(client.create).toHaveBeenCalledTimes(1); + expect(result).toEqual( + expect.objectContaining({ + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + type, + attributes, + namespaces: [namespace], + }) + ); + }); + + test(`calls checkAuthorization with type, actions, and namespace`, async () => { + await repository.create(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, attributes, { + namespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['create']); + const expectedSpaces = new Set([namespace]); + const expectedTypes = new Set([MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls checkAuthorization with type, actions, namespace, and initial namespaces`, async () => { + await repository.create(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, attributes, { + namespace, + initialNamespaces: multiNamespaceObjNamespaces, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['create']); + const expectedSpaces = new Set(multiNamespaceObjNamespaces); + const expectedTypes = new Set([MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with action, type map, and auth map`, async () => { + setupCheckAuthorized(mockSecurityExt); + + await repository.create(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, attributes, { + namespace, + initialNamespaces: multiNamespaceObjNamespaces, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'create', + }) + ); + + const expectedTypesAndSpaces = new Map([ + [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, new Set(multiNamespaceObjNamespaces)], + ]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + + test(`calls redactNamespaces with authorization map`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + await repository.create(type, attributes, { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledWith( + expect.objectContaining({ + typeMap: authMap, + savedObject: expect.objectContaining({ + type, + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + namespaces: [namespace], + }), + }) + ); + }); + + test(`adds audit event when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + await repository.create(type, attributes, { namespace }); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.CREATE, + savedObject: { + type, + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + }, + error: undefined, + outcome: 'unknown', + }); + }); + + test(`adds audit event when not successful`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect(repository.create(type, attributes, { namespace })).rejects.toThrow( + enforceError + ); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.CREATE, + savedObject: { + type, + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + }, + error: enforceError, + }); + }); + }); + + describe('#delete', () => { + beforeAll(() => { + mockDeleteLegacyUrlAliases.mockResolvedValue(); + }); + + afterAll(() => { + mockDeleteLegacyUrlAliases.mockClear(); + }); + + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect( + deleteSuccess(client, repository, registry, type, id, { namespace }) + ).rejects.toThrow(checkAuthError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + deleteSuccess(client, repository, registry, type, id, { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`returns empty object result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const result = await deleteSuccess(client, repository, registry, type, id, { + namespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); + expect(result).toEqual({}); + }); + + test(`calls checkAuthorization with type, actions, and namespaces`, async () => { + await deleteSuccess(client, repository, registry, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, id, { + namespace, + force: true, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['delete']); + const expectedSpaces = new Set([namespace]); + const expectedTypes = new Set([MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with action, type map, and auth map`, async () => { + setupCheckAuthorized(mockSecurityExt); + + await deleteSuccess(client, repository, registry, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, id, { + namespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'delete', + }) + ); + const expectedTypesAndSpaces = new Map([ + [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, new Set([namespace])], + ]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + + test(`adds audit event when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + await deleteSuccess(client, repository, registry, type, id, { namespace }); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.DELETE, + savedObject: { type, id }, + error: undefined, + outcome: 'unknown', + }); + }); + + test(`adds audit event when not successful`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + deleteSuccess(client, repository, registry, type, id, { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.DELETE, + savedObject: { type, id }, + error: enforceError, + }); + }); + }); + + describe('#removeReferencesTo', () => { + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect( + removeReferencesToSuccess(client, repository, type, id, { namespace }) + ).rejects.toThrow(checkAuthError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + removeReferencesToSuccess(client, repository, type, id, { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const result = await removeReferencesToSuccess(client, repository, type, id, { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); + expect(result).toEqual(expect.objectContaining({ updated: REMOVE_REFS_COUNT })); + }); + + test(`calls checkAuthorization with type, actions, and namespaces`, async () => { + await removeReferencesToSuccess(client, repository, type, id, { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['delete']); + const expectedSpaces = new Set([namespace]); + const expectedTypes = new Set([type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with type/namespace map`, async () => { + setupCheckAuthorized(mockSecurityExt); + + await removeReferencesToSuccess(client, repository, type, id, { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'delete', + }) + ); + + const expectedTypesAndSpaces = new Map([[type, new Set([namespace])]]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + + test(`adds audit event when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + await removeReferencesToSuccess(client, repository, type, id, { namespace }); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.REMOVE_REFERENCES, + savedObject: { type, id }, + error: undefined, + outcome: 'unknown', + }); + }); + + test(`adds audit event when not successful`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + removeReferencesToSuccess(client, repository, type, id, { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.REMOVE_REFERENCES, + savedObject: { type, id }, + error: enforceError, + }); + }); + }); + + describe('#checkConflicts', () => { + const obj1 = { type, id: 'one' }; + const obj2 = { type, id: 'two' }; + + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect( + checkConflictsSuccess(client, repository, registry, [obj1, obj2], { namespace }) + ).rejects.toThrow(checkAuthError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + checkConflictsSuccess(client, repository, registry, [obj1, obj2], { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const result = await checkConflictsSuccess(client, repository, registry, [obj1, obj2], { + namespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); + // Default mock mget makes each object found + expect(result).toEqual( + expect.objectContaining({ + errors: [ + { + error: { + error: 'Conflict', + message: `Saved object [${obj1.type}/${obj1.id}] conflict`, + statusCode: 409, + }, + id: obj1.id, + type: obj1.type, + }, + { + error: { + error: 'Conflict', + message: `Saved object [${obj2.type}/${obj2.id}] conflict`, + statusCode: 409, + }, + id: obj2.id, + type: obj2.type, + }, + ], + }) + ); + }); + + test(`calls checkAuthorization with type, actions, and namespaces`, async () => { + await checkConflictsSuccess(client, repository, registry, [obj1, obj2], { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['bulk_create']); + const expectedSpaces = new Set([namespace]); + const expectedTypes = new Set([type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with type/namespace map`, async () => { + setupCheckAuthorized(mockSecurityExt); + + await checkConflictsSuccess(client, repository, registry, [obj1, obj2], { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'bulk_create', + }) + ); + const expectedTypesAndSpaces = new Map([[type, new Set([namespace])]]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + }); + + describe('#openPointInTimeForType', () => { + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect(repository.openPointInTimeForType(type)).rejects.toThrow(checkAuthError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + + client.openPointInTime.mockResponseOnce({ id }); + const result = await repository.openPointInTimeForType(type); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + expect(client.openPointInTime).toHaveBeenCalledTimes(1); + expect(result).toEqual(expect.objectContaining({ id })); + }); + + test(`adds audit event when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + + client.openPointInTime.mockResponseOnce({ id }); + await repository.openPointInTimeForType(type); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.OPEN_POINT_IN_TIME, + outcome: 'unknown', + }); + }); + + test(`throws an error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + await expect(repository.openPointInTimeForType(type)).rejects.toThrowError(); + }); + + test(`adds audit event when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + + await expect(repository.openPointInTimeForType(type)).rejects.toThrowError(); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.OPEN_POINT_IN_TIME, + error: new Error('User is unauthorized for any requested types/spaces.'), + }); + }); + + test(`calls checkAuthorization with type, actions, and namespaces`, async () => { + setupCheckAuthorized(mockSecurityExt); + client.openPointInTime.mockResponseOnce({ id }); + await repository.openPointInTimeForType(type, { namespaces: [namespace] }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['open_point_in_time']); + const expectedSpaces = new Set([namespace]); + const expectedTypes = new Set([type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + }); + + describe('#closePointInTime', () => { + test(`returns result of es client`, async () => { + const expectedResult = { succeeded: true, num_freed: 3 }; + client.closePointInTime.mockResponseOnce(expectedResult); + const result = await repository.closePointInTime(id); + expect(client.closePointInTime).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedResult); + }); + + test(`adds audit event`, async () => { + await repository.closePointInTime(id); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.CLOSE_POINT_IN_TIME, + outcome: 'unknown', + }); + }); + }); + + describe('#find', () => { + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect(findSuccess(client, repository, { type })).rejects.toThrow(checkAuthError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`returns empty result when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + + const result = await repository.find({ type }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(result).toEqual( + expect.objectContaining({ + saved_objects: [], + total: 0, + }) + ); + }); + + test(`calls es search with only authorized spaces when partially authorized`, async () => { + // Setup partial authorization with the specific type and space of the current test definition + const authRecord: Record = { + find: { authorizedSpaces: [namespace] }, + }; + mockSecurityExt.checkAuthorization.mockResolvedValue({ + status: 'partially_authorized', + typeMap: Object.freeze(new Map([[type, authRecord]])), + }); + + await findSuccess(client, repository, { type, namespaces: [namespace, 'ns-1'] }); + expect(mockGetSearchDsl.mock.calls[0].length).toBe(3); // Find success verifies this is called once, this shouyld always pass + const { + typeToNamespacesMap: actualMap, + }: { typeToNamespacesMap: Map } = + mockGetSearchDsl.mock.calls[0][2]; + + expect(actualMap).toBeDefined(); + const expectedMap = new Map(); + expectedMap.set(type, [namespace]); + expect(namespaceMapsAreEqual(actualMap, expectedMap)).toBeTruthy(); + }); + + test(`returns result of es find when fully authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const { result, generatedResults } = await findSuccess( + client, + repository, + { type }, + namespace + ); + const count = generatedResults.hits.hits.length; + + expect(result.total).toBe(count); + expect(result.saved_objects).toHaveLength(count); + + generatedResults.hits.hits.forEach((doc, i) => { + expect(result.saved_objects[i]).toEqual({ + id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globalType)\:/, ''), + type: doc._source!.type, + originId: doc._source!.originId, + ...mockTimestampFields, + version: mockVersion, + score: doc._score, + attributes: doc._source![doc._source!.type], + references: [], + namespaces: doc._source!.type === NAMESPACE_AGNOSTIC_TYPE ? undefined : [namespace], + }); + }); + }); + + test(`uses the authorization map when partially authorized`, async () => { + setupCheckPartiallyAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + await findSuccess( + client, + repository, + { type: [type, NAMESPACE_AGNOSTIC_TYPE], namespaces: [namespace, 'ns-1'] }, // include multiple types and spaces + namespace + ); + + // make sure the authorized map gets passed to the es client call + expect(mockGetSearchDsl).toHaveBeenCalledWith( + expect.objectContaining({}), + expect.objectContaining({}), + expect.objectContaining({ + typeToNamespacesMap: authMap, + }) + ); + }); + + test(`returns result of es find when partially authorized`, async () => { + setupCheckPartiallyAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const { result, generatedResults } = await findSuccess( + client, + repository, + { type }, + namespace + ); + const count = generatedResults.hits.hits.length; + + expect(result.total).toBe(count); + expect(result.saved_objects).toHaveLength(count); + + generatedResults.hits.hits.forEach((doc, i) => { + expect(result.saved_objects[i]).toEqual({ + id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globalType)\:/, ''), + type: doc._source!.type, + originId: doc._source!.originId, + ...mockTimestampFields, + version: mockVersion, + score: doc._score, + attributes: doc._source![doc._source!.type], + references: [], + namespaces: doc._source!.type === NAMESPACE_AGNOSTIC_TYPE ? undefined : [namespace], + }); + }); + }); + + test(`calls checkAuthorization with type, actions, and namespaces`, async () => { + setupCheckPartiallyAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + await findSuccess(client, repository, { type, namespaces: [namespace] }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(2); + const expectedActions = new Set(['find']); + const expectedSpaces = new Set([namespace]); + const expectedTypes = new Set([type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls redactNamespaces with authorization map`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const { generatedResults } = await findSuccess(client, repository, { + type, + namespaces: [namespace], + }); + + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledTimes( + generatedResults.hits.hits.length + ); + expect(mockSecurityExt.redactNamespaces).toBeCalledWith( + expect.objectContaining({ + typeMap: authMap, + }) + ); + }); + + test(`adds audit per object event when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const { generatedResults } = await findSuccess(client, repository, { + type, + namespaces: [namespace], + }); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes( + generatedResults.hits.hits.length + ); + + generatedResults.hits.hits.forEach((doc, i) => { + expect(mockSecurityExt.addAuditEvent.mock.calls[i]).toEqual([ + { + action: AuditAction.FIND, + savedObject: { + type: doc._source!.type, + id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globalType)\:/, ''), + }, + }, + ]); + }); + }); + + test(`adds audit event when not successful`, async () => { + setupCheckUnauthorized(mockSecurityExt); + + await repository.find({ type }); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.FIND, + error: new Error('User is unauthorized for any requested types/spaces.'), + }); + }); + }); + + describe('#bulkGet', () => { + const obj1: SavedObject = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Testing' }, + references: [ + { + name: 'ref_0', + type: 'test', + id: '1', + }, + ], + namespaces: [namespace], + originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case + }; + const obj2: SavedObject = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Testing' }, + references: [ + { + name: 'ref_0', + type: 'test', + id: '2', + }, + ], + namespaces: [namespace], + }; + + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect( + bulkGetSuccess(client, repository, registry, [obj1, obj2], { namespace }) + ).rejects.toThrow(checkAuthError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + bulkGetSuccess(client, repository, registry, [obj1, obj2], { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'bulk_get', + }) + ); + + const expectedTypesAndSpaces = new Map([ + [obj1.type, new Set([namespace])], + [obj2.type, new Set([namespace])], + ]); + + const { typesAndSpaces: actualTypesAndSpaces } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const { result, mockResponse } = await bulkGetSuccess( + client, + repository, + registry, + [obj1, obj2], + { namespace } + ); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [ + expectBulkGetResult( + obj1, + mockResponse.docs[0] as estypes.GetGetResult + ), + expectBulkGetResult( + obj2, + mockResponse.docs[1] as estypes.GetGetResult + ), + ], + }); + }); + + test(`calls checkAuthorization with type, actions, namespace, and object namespaces`, async () => { + const objA = { + ...obj1, + type: MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, // replace the type to a mult-namespace type for this test to be thorough + namespaces: multiNamespaceObjNamespaces, // include multiple spaces + }; + const objB = { ...obj2, namespaces: ['ns-3'] }; // use a different namespace than the options namespace; + const optionsNamespace = 'ns-4'; + + await bulkGetSuccess(client, repository, registry, [objA, objB], { + namespace: optionsNamespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['bulk_get']); + const expectedSpaces = new Set([optionsNamespace, ...objA.namespaces, ...objB.namespaces]); + const expectedTypes = new Set([objA.type, objB.type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with action, type map, and auth map`, async () => { + const objA = { + ...obj1, + type: MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, // replace the type to a mult-namespace type for this test to be thorough + namespaces: multiNamespaceObjNamespaces, // include multiple spaces + }; + const objB = { ...obj2, namespaces: ['ns-3'] }; // use a different namespace than the options namespace; + const optionsNamespace = 'ns-4'; + + setupCheckAuthorized(mockSecurityExt); + + await bulkGetSuccess(client, repository, registry, [objA, objB], { + namespace: optionsNamespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'bulk_get', + }) + ); + + const expectedTypesAndSpaces = new Map([ + [objA.type, new Set([optionsNamespace, ...objA.namespaces])], + [objB.type, new Set([optionsNamespace, ...objB.namespaces])], + ]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); // ToDo? reference comparison ok, object is frozen + }); + + test(`calls redactNamespaces with authorization map`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const objects = [obj1, obj2]; + + await bulkGetSuccess(client, repository, registry, objects, { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledTimes(2); + + objects.forEach((obj, i) => { + const { savedObject, typeMap } = mockSecurityExt.redactNamespaces.mock.calls[i][0]; + expect(savedObject).toEqual( + expect.objectContaining({ + type: obj.type, + id: obj.id, + namespaces: [namespace], + }) + ); + expect(typeMap).toBe(authMap); + }); + }); + + test(`adds audit event per object when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + const objects = [obj1, obj2]; + await bulkGetSuccess(client, repository, registry, objects, { namespace }); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.GET, + savedObject: { type: obj.type, id: obj.id }, + }); + }); + }); + + test(`adds audit event per object when not successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + const objects = [obj1, obj2]; + await expect( + bulkGetSuccess(client, repository, registry, objects, { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.GET, + savedObject: { type: obj.type, id: obj.id }, + error: enforceError, + }); + }); + }); + }); + + describe('#bulkCreate', () => { + beforeEach(() => { + mockPreflightCheckForCreate.mockReset(); + mockPreflightCheckForCreate.mockImplementation(({ objects }) => { + // eslint-disable-next-line @typescript-eslint/no-shadow + return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default + }); + }); + + const obj1 = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2 = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect(bulkCreateSuccess(client, repository, [obj1, obj2])).rejects.toThrow( + checkAuthError + ); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const objects = [obj1, obj2]; + const result = await bulkCreateSuccess(client, repository, objects); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: objects.map((obj) => expectCreateResult(obj)), + }); + }); + + test(`calls checkAuthorization with type, actions, and namespace`, async () => { + setupCheckAuthorized(mockSecurityExt); + + await bulkCreateSuccess(client, repository, [obj1, obj2], { + namespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['bulk_create']); + const expectedSpaces = new Set([namespace]); + const expectedTypes = new Set([obj1.type, obj2.type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls checkAuthorization with type, actions, namespace, and initial namespaces`, async () => { + const objA = { + ...obj1, + type: MULTI_NAMESPACE_TYPE, + initialNamespaces: ['ns-1', 'ns-2'], + }; + const objB = { + ...obj2, + type: MULTI_NAMESPACE_TYPE, + initialNamespaces: ['ns-3', 'ns-4'], + }; + const optionsNamespace = 'ns-5'; + + setupCheckAuthorized(mockSecurityExt); + + await bulkCreateSuccess(client, repository, [objA, objB], { + namespace: optionsNamespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + + const expectedActions = new Set(['bulk_create']); + const expectedSpaces = new Set([ + optionsNamespace, + ...objA.initialNamespaces, + ...objB.initialNamespaces, + ]); + const expectedTypes = new Set([objA.type, objB.type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with action, type map, and auth map`, async () => { + const objA = { + ...obj1, + type: MULTI_NAMESPACE_TYPE, + initialNamespaces: ['ns-1', 'ns-2'], + }; + const objB = { + ...obj2, + type: MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, + initialNamespaces: ['ns-3', 'ns-4'], + }; + const optionsNamespace = 'ns-5'; + + setupCheckAuthorized(mockSecurityExt); + + await bulkCreateSuccess(client, repository, [objA, objB], { + namespace: optionsNamespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'bulk_create', + }) + ); + const expectedTypesAndSpaces = new Map([ + [objA.type, new Set([optionsNamespace, ...objA.initialNamespaces])], + [objB.type, new Set([optionsNamespace, ...objB.initialNamespaces])], + ]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + + test(`calls redactNamespaces with authorization map`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const objects = [obj1, obj2]; + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledTimes(2); + + objects.forEach((obj, i) => { + const { savedObject, typeMap } = mockSecurityExt.redactNamespaces.mock.calls[i][0]; + expect(savedObject).toEqual( + expect.objectContaining({ + type: obj.type, + id: obj.id, + namespaces: [namespace], + }) + ); + expect(typeMap).toBe(authMap); + }); + }); + + test(`adds audit event per object when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + const objects = [obj1, obj2]; + await bulkCreateSuccess(client, repository, objects, { namespace }); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.CREATE, + savedObject: { type: obj.type, id: obj.id }, + outcome: 'unknown', + }); + }); + }); + + test(`adds audit event per object when not successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + const objects = [obj1, obj2]; + await expect(bulkCreateSuccess(client, repository, objects, { namespace })).rejects.toThrow( + enforceError + ); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.CREATE, + savedObject: { type: obj.type, id: obj.id }, + error: enforceError, + }); + }); + }); + }); + + describe('#bulkUpdate', () => { + const obj1: SavedObjectsBulkUpdateObject = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + }; + const obj2: SavedObjectsBulkUpdateObject = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + }; + + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect(bulkUpdateSuccess(client, repository, registry, [obj1, obj2])).rejects.toThrow( + checkAuthError + ); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect(bulkUpdateSuccess(client, repository, registry, [obj1, obj2])).rejects.toThrow( + enforceError + ); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const objects = [obj1, obj2]; + const result = await bulkUpdateSuccess(client, repository, registry, objects); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: objects.map((obj) => expectUpdateResult(obj)), + }); + }); + + test(`calls checkAuthorization with type, actions, and namespace`, async () => { + setupCheckAuthorized(mockSecurityExt); + + await bulkUpdateSuccess(client, repository, registry, [obj1, obj2], { + namespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['bulk_update']); + const expectedSpaces = new Set([namespace]); + const expectedTypes = new Set([obj1.type, obj2.type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls checkAuthorization with type, actions, namespace, and object namespaces`, async () => { + const objA = { + ...obj1, + namespace: 'ns-1', // object namespace + }; + const objB = { + ...obj2, + namespace: 'ns-2', // object namespace + }; + + setupCheckAuthorized(mockSecurityExt); + + await bulkUpdateSuccess(client, repository, registry, [objA, objB], { + namespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['bulk_update']); + const expectedSpaces = new Set([namespace, objA.namespace, objB.namespace]); + const expectedTypes = new Set([objA.type, objB.type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with action, type map, and auth map`, async () => { + const objA = { + ...obj1, + namespace: 'ns-1', // object namespace + }; + const objB = { + ...obj2, + namespace: 'ns-2', // object namespace + }; + + setupCheckAuthorized(mockSecurityExt); + + await bulkUpdateSuccess(client, repository, registry, [objA, objB], { + namespace, + }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'bulk_update', + }) + ); + + const expectedTypesAndSpaces = new Map([ + [objA.type, new Set([namespace, objA.namespace])], + [objB.type, new Set([namespace, objB.namespace])], + ]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + + test(`calls redactNamespaces with authorization map`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const objects = [obj1, obj2]; + await bulkUpdateSuccess(client, repository, registry, objects, { namespace }); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledTimes(2); + + objects.forEach((obj, i) => { + const { savedObject, typeMap } = mockSecurityExt.redactNamespaces.mock.calls[i][0]; + expect(savedObject).toEqual( + expect.objectContaining({ + type: obj.type, + id: obj.id, + namespaces: [namespace], + }) + ); + expect(typeMap).toBe(authMap); + }); + }); + + test(`adds audit event per object when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + const objects = [obj1, obj2]; + await bulkUpdateSuccess(client, repository, registry, objects, { namespace }); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.UPDATE, + savedObject: { type: obj.type, id: obj.id }, + outcome: 'unknown', + }); + }); + }); + + test(`adds audit event per object when not successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + const objects = [obj1, obj2]; + await expect( + bulkUpdateSuccess(client, repository, registry, objects, { namespace }) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.UPDATE, + savedObject: { type: obj.type, id: obj.id }, + error: enforceError, + }); + }); + }); + }); + + describe('#bulkDelete', () => { + beforeEach(() => { + mockDeleteLegacyUrlAliases.mockClear(); + mockDeleteLegacyUrlAliases.mockResolvedValue(); + }); + + const obj1: SavedObjectsBulkUpdateObject = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + }; + const obj2: SavedObjectsBulkUpdateObject = { + type: MULTI_NAMESPACE_TYPE, + id: 'logstash-*', + attributes: { title: 'Test Two' }, + }; + const testObjs = [obj1, obj2]; + const options = { + namespace, + force: true, + }; + const internalOptions = { + mockMGetResponseObjects: [ + { + ...obj1, + initialNamespaces: undefined, + }, + { + ...obj2, + initialNamespaces: [namespace, 'NS-1', 'NS-2'], + }, + ], + }; + + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + await expect( + bulkDeleteSuccess(client, repository, registry, testObjs, options) + ).rejects.toThrow(checkAuthError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect( + bulkDeleteSuccess(client, repository, registry, testObjs, options) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`returns result when authorized`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const result = await bulkDeleteSuccess( + client, + repository, + registry, + testObjs, + options, + internalOptions + ); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + statuses: testObjs.map((obj) => createBulkDeleteSuccessStatus(obj)), + }); + }); + + test(`calls checkAuthorization with type, actions, and namespaces`, async () => { + setupCheckAuthorized(mockSecurityExt); + + await bulkDeleteSuccess(client, repository, registry, testObjs, options, internalOptions); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['bulk_delete']); + const expectedSpaces = new Set(internalOptions.mockMGetResponseObjects[1].initialNamespaces); + const expectedTypes = new Set([obj1.type, obj2.type]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with action, type map, and auth map`, async () => { + setupCheckAuthorized(mockSecurityExt); + + await bulkDeleteSuccess(client, repository, registry, testObjs, options, internalOptions); + + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'bulk_delete', + }) + ); + + const expectedTypesAndSpaces = new Map([ + [obj1.type, new Set([namespace])], + [obj2.type, new Set([namespace])], // only need authz in current space + ]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + + test(`adds audit event per object when successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + + const objects = [obj1, obj2]; + await bulkDeleteSuccess(client, repository, registry, objects, options); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.DELETE, + savedObject: { type: obj.type, id: obj.id }, + outcome: 'unknown', + }); + }); + }); + + test(`adds audit event per object when not successful`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + const objects = [obj1, obj2]; + await expect( + bulkDeleteSuccess(client, repository, registry, objects, options) + ).rejects.toThrow(enforceError); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.DELETE, + savedObject: { type: obj.type, id: obj.id }, + error: enforceError, + }); + }); + }); + }); +}); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts new file mode 100644 index 0000000000000..c5c90b048dc31 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts @@ -0,0 +1,925 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + pointInTimeFinderMock, + mockGetCurrentTime, + mockPreflightCheckForCreate, + mockUpdateObjectsSpaces, + mockGetSearchDsl, + mockCollectMultiNamespaceReferences, + mockInternalBulkResolve, + mockDeleteLegacyUrlAliases, +} from './repository.test.mock'; + +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +import { SavedObjectsRepository } from './repository'; +import { loggerMock } from '@kbn/logging-mocks'; +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; +import { + SavedObjectsResolveResponse, + SavedObjectsBulkUpdateObject, +} from '@kbn/core-saved-objects-api-server'; +import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal'; +import { SavedObject } from '@kbn/core-saved-objects-common'; +import { + ISavedObjectsSpacesExtension, + ISavedObjectsSecurityExtension, +} from '@kbn/core-saved-objects-server'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server'; +import { kibanaMigratorMock } from '../mocks'; +import { + createRegistry, + createDocumentMigrator, + mappings, + DEFAULT_SPACE, + createSpySerializer, + mockTimestamp, + CUSTOM_INDEX_TYPE, + getMockGetResponse, + updateSuccess, + deleteSuccess, + removeReferencesToSuccess, + MULTI_NAMESPACE_ISOLATED_TYPE, + checkConflictsSuccess, + MULTI_NAMESPACE_TYPE, + bulkGetSuccess, + bulkCreateSuccess, + bulkUpdateSuccess, + findSuccess, + setupCheckUnauthorized, + generateIndexPatternSearchResults, + bulkDeleteSuccess, +} from '../test_helpers/repository.test.common'; +import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock'; + +// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository +// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. + +const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; + +describe('SavedObjectsRepository Spaces Extension', () => { + let client: ReturnType; + let repository: SavedObjectsRepository; + let migrator: ReturnType; + let logger: ReturnType; + let serializer: jest.Mocked; + let mockSpacesExt: jest.Mocked; + let mockSecurityExt: jest.Mocked; + + const registry = createRegistry(); + const documentMigrator = createDocumentMigrator(registry); + + // const currentSpace = 'foo-namespace'; + const defaultOptions = { ignore: [404], maxRetries: 0, meta: true }; // These are just the hard-coded options passed in via the repo + + const instantiateRepository = () => { + const allTypes = registry.getAllTypes().map((type) => type.name); + const allowedTypes = [...new Set(allTypes.filter((type) => !registry.isHidden(type)))]; + + // @ts-expect-error must use the private constructor to use the mocked serializer + return new SavedObjectsRepository({ + index: '.kibana-test', + mappings, + client, + migrator, + typeRegistry: registry, + serializer, + allowedTypes, + logger, + extensions: { spacesExtension: mockSpacesExt, securityExtension: mockSecurityExt }, + }); + }; + + const availableSpaces = [ + { id: 'default', name: '', disabledFeatures: [] }, + { id: 'ns-1', name: '', disabledFeatures: [] }, + { id: 'ns-2', name: '', disabledFeatures: [] }, + { id: 'ns-3', name: '', disabledFeatures: [] }, + { id: 'ns-4', name: '', disabledFeatures: [] }, + ]; + + [ + { id: DEFAULT_SPACE, expectedNamespace: undefined }, + { id: 'ns-1', expectedNamespace: 'ns-1' }, + ].forEach((currentSpace) => { + describe(`${currentSpace.id} space`, () => { + beforeEach(() => { + pointInTimeFinderMock.mockClear(); + client = elasticsearchClientMock.createElasticsearchClient(); + migrator = kibanaMigratorMock.create(); + documentMigrator.prepareMigrations(); + migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate); + migrator.runMigrations = jest.fn().mockResolvedValue([{ status: 'skipped' }]); + logger = loggerMock.create(); + + // create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation + serializer = createSpySerializer(registry); + + // create a mock saved objects spaces extension + mockSpacesExt = savedObjectsExtensionsMock.createSpacesExtension(); + + mockGetCurrentTime.mockReturnValue(mockTimestamp); + mockGetSearchDsl.mockClear(); + + repository = instantiateRepository(); + + mockSpacesExt.getCurrentNamespace.mockImplementation((namespace: string | undefined) => { + if (namespace) { + throw SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED); + } + return currentSpace.expectedNamespace; + }); + + mockSpacesExt.getSearchableNamespaces.mockImplementation( + (namespaces: string[] | undefined): Promise => { + if (!namespaces) { + return Promise.resolve(['current-space'] as string[]); + } else if (!namespaces.length) { + return Promise.resolve(namespaces); + } + + if (namespaces?.includes('*')) { + return Promise.resolve(availableSpaces.map((space) => space.id)); + } else { + return Promise.resolve( + namespaces?.filter((namespace) => + availableSpaces.some((space) => space.id === namespace) + ) + ); + } + } + ); + }); + + describe('#get', () => { + test(`throws error if options.namespace is specified`, async () => { + // Just makes sure the error propagates from the extension through the repo call + await expect(repository.get('foo', '', { namespace: 'bar' })).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith('bar'); + }); + + test(`supplements id with the current namespace`, async () => { + const type = CUSTOM_INDEX_TYPE; + const id = 'some-id'; + + const response = getMockGetResponse(registry, { + type, + id, + }); + + client.get.mockResponseOnce(response); + await repository.get(type, id); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${ + currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : '' + }${type}:${id}`, + }), + defaultOptions + ); + }); + }); + + describe('#update', () => { + test(`throws error if options.namespace is specified`, async () => { + // Just makes sure the error propagates from the extension through the repo call + await expect( + repository.update('foo', 'some-id', { attr: 'value' }, { namespace: 'bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + const type = CUSTOM_INDEX_TYPE; + const id = 'some-id'; + + await updateSuccess( + client, + repository, + registry, + type, + id, + {}, + { upsert: true }, + { mockGetResponseValue: { found: false } as estypes.GetResponse } + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(client.update).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${ + currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : '' + }${type}:${id}`, + body: expect.objectContaining({ + upsert: expect.objectContaining( + currentSpace.expectedNamespace + ? { + namespace: currentSpace.expectedNamespace, + } + : {} + ), + }), + }), + { maxRetries: 0 } + ); + }); + }); + + describe('#create', () => { + test(`throws error if options.namespace is specified`, async () => { + await expect( + repository.create('foo', { attr: 'value' }, { namespace: 'bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + const type = CUSTOM_INDEX_TYPE; + const attributes = { attr: 'value' }; + + await repository.create(type, { attr: 'value' }); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(client.create).toHaveBeenCalledTimes(1); + const regex = new RegExp( + `${ + currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : '' + }${type}:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}` + ); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(regex), + body: expect.objectContaining( + currentSpace.expectedNamespace + ? { + namespace: currentSpace.expectedNamespace, + type: CUSTOM_INDEX_TYPE, + customIndex: attributes, + } + : { type: CUSTOM_INDEX_TYPE, customIndex: attributes } + ), + }), + { maxRetries: 0, meta: true } + ); + }); + }); + + describe('#delete', () => { + test(`throws error if options.namespace is specified`, async () => { + await expect( + repository.delete('foo', 'some-id', { namespace: 'bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar'); + }); + + test(`supplements id with the current namespace`, async () => { + const type = CUSTOM_INDEX_TYPE; + const id = 'some-id'; + + await deleteSuccess(client, repository, registry, type, id); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(client.delete).toHaveBeenCalledTimes(1); + const regex = new RegExp( + `${ + currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : '' + }${type}:${id}` + ); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(regex), + }), + { ignore: [404], maxRetries: 0, meta: true } + ); + }); + }); + + describe('#removeReferencesTo', () => { + test(`throws error if options.namespace is specified`, async () => { + await expect( + repository.removeReferencesTo('foo', 'some-id', { namespace: 'bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + const type = CUSTOM_INDEX_TYPE; + const id = 'some-id'; + + const query = { query: 1, aggregations: 2 }; + mockGetSearchDsl.mockReturnValue(query); + + await removeReferencesToSuccess(client, repository, type, id); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); + expect(mockGetSearchDsl).toHaveBeenCalledTimes(1); + expect(mockGetSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + namespaces: currentSpace.expectedNamespace + ? [currentSpace.expectedNamespace] + : undefined, + hasReference: { type, id }, + }) + ); + }); + }); + + describe('#checkConflicts', () => { + test(`throws error if options.namespace is specified`, async () => { + await expect( + repository.checkConflicts(undefined, { namespace: 'bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + const obj1 = { type: CUSTOM_INDEX_TYPE, id: 'one' }; + const obj2 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'two' }; + + await checkConflictsSuccess(client, repository, registry, [obj1, obj2]); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(client.mget).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + docs: expect.arrayContaining([ + expect.objectContaining({ + _id: `${ + currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : '' + }${obj1.type}:${obj1.id}`, + }), + expect.objectContaining({ + _id: `${obj2.type}:${obj2.id}`, + }), + ]), + }), + }), + { ignore: [404], maxRetries: 0, meta: true } + ); + }); + }); + + describe('#updateObjectSpaces', () => { + afterEach(() => { + mockUpdateObjectsSpaces.mockReset(); + }); + + test(`throws error if options.namespace is specified`, async () => { + await expect( + repository.updateObjectsSpaces([], [], [], { namespace: 'bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + const obj1 = { type: CUSTOM_INDEX_TYPE, id: 'one' }; + const obj2 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'two' }; + const spacesToAdd = ['space-x']; + const spacesToRemove = ['space-y']; + + await repository.updateObjectsSpaces([obj1, obj2], spacesToAdd, spacesToRemove); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(mockUpdateObjectsSpaces).toHaveBeenCalledTimes(1); + expect(mockUpdateObjectsSpaces).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [obj1, obj2], + options: { + namespace: currentSpace.expectedNamespace + ? currentSpace.expectedNamespace + : undefined, + }, + }) + ); + }); + }); + + describe('#collectMultiNamespaceReferences', () => { + afterEach(() => { + mockCollectMultiNamespaceReferences.mockReset(); + }); + + test(`throws error if options.namespace is specified`, async () => { + await expect( + repository.collectMultiNamespaceReferences([], { namespace: 'bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + const obj1 = { type: CUSTOM_INDEX_TYPE, id: 'one' }; + const obj2 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'two' }; + + await repository.collectMultiNamespaceReferences([obj1, obj2]); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(mockCollectMultiNamespaceReferences).toHaveBeenCalledTimes(1); + expect(mockCollectMultiNamespaceReferences).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [obj1, obj2], + options: { + namespace: currentSpace.expectedNamespace + ? currentSpace.expectedNamespace + : undefined, + }, + }) + ); + }); + }); + + describe('#openPointInTimeForType', () => { + test(`propagates options.namespaces: ['*']`, async () => { + await repository.openPointInTimeForType(CUSTOM_INDEX_TYPE, { namespaces: ['*'] }); + expect(mockSpacesExt.getSearchableNamespaces).toBeCalledTimes(1); + expect(mockSpacesExt.getSearchableNamespaces).toBeCalledWith(['*']); + }); + + test(`supplements options with the current namespace`, async () => { + await repository.openPointInTimeForType(CUSTOM_INDEX_TYPE); + expect(mockSpacesExt.getSearchableNamespaces).toBeCalledTimes(1); + expect(mockSpacesExt.getSearchableNamespaces).toBeCalledWith(undefined); // will resolve current space + }); + }); + + describe('#resolve', () => { + afterEach(() => { + mockInternalBulkResolve.mockReset(); + }); + + test(`throws error if options.namespace is specified`, async () => { + await expect( + repository.resolve('foo', 'some-id', { namespace: 'bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + const type = CUSTOM_INDEX_TYPE; + const id = 'some-id'; + + const expectedResult: SavedObjectsResolveResponse = { + saved_object: { type, id, attributes: {}, references: [] }, + outcome: 'exactMatch', + }; + mockInternalBulkResolve.mockResolvedValue({ resolved_objects: [expectedResult] }); + await repository.resolve(type, id); + expect(mockInternalBulkResolve).toHaveBeenCalledTimes(1); + expect(mockInternalBulkResolve).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [{ type, id }], + options: { + namespace: currentSpace.expectedNamespace + ? currentSpace.expectedNamespace + : undefined, + }, + }) + ); + }); + }); + + describe('#bulkGet', () => { + const obj1: SavedObject = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Testing' }, + references: [ + { + name: 'ref_0', + type: 'test', + id: '1', + }, + ], + originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case + }; + const obj2: SavedObject = { + type: MULTI_NAMESPACE_TYPE, + id: 'logstash-*', + attributes: { title: 'Testing' }, + references: [ + { + name: 'ref_0', + type: 'test', + id: '2', + }, + ], + }; + + test(`throws error if options.namespace is specified`, async () => { + await expect( + bulkGetSuccess(client, repository, registry, [obj1, obj2], { namespace: 'foo-bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('foo-bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + await bulkGetSuccess(client, repository, registry, [obj1, obj2]); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(mockSpacesExt.getSearchableNamespaces).not.toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + docs: expect.arrayContaining([ + expect.objectContaining({ + _id: `${ + currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : '' + }${obj1.type}:${obj1.id}`, + }), + expect.objectContaining({ + _id: `${obj2.type}:${obj2.id}`, + }), + ]), + }), + }), + { ignore: [404], maxRetries: 0, meta: true } + ); + }); + + test(`calls getSearchableNamespaces with '*' when object namespaces includes '*'`, async () => { + await bulkGetSuccess(client, repository, registry, [ + obj1, + { ...obj2, namespaces: ['*'] }, + ]); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(mockSpacesExt.getSearchableNamespaces).toHaveBeenCalledTimes(1); + expect(mockSpacesExt.getSearchableNamespaces).toHaveBeenCalledWith(['*']); + }); + }); + + describe('#bulkCreate', () => { + beforeEach(() => { + mockPreflightCheckForCreate.mockReset(); + mockPreflightCheckForCreate.mockImplementation(({ objects }) => { + return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default + }); + }); + + const obj1 = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2 = { + type: MULTI_NAMESPACE_TYPE, + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + + test(`throws error if options.namespace is specified`, async () => { + await expect( + bulkCreateSuccess(client, repository, [obj1, obj2], { namespace: 'foo-bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('foo-bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(mockSpacesExt.getSearchableNamespaces).not.toHaveBeenCalled(); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.arrayContaining([ + expect.objectContaining({ + create: expect.objectContaining({ + _id: `${ + currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : '' + }${obj1.type}:${obj1.id}`, + }), + }), + expect.objectContaining({ + create: expect.objectContaining({ + _id: `${obj2.type}:${obj2.id}`, + }), + }), + ]), + }), + { maxRetries: 0 } + ); + }); + }); + + describe('#bulkUpdate', () => { + const obj1: SavedObjectsBulkUpdateObject = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + }; + const obj2: SavedObjectsBulkUpdateObject = { + type: MULTI_NAMESPACE_TYPE, + id: 'logstash-*', + attributes: { title: 'Test Two' }, + }; + + test(`throws error if options.namespace is specified`, async () => { + await expect( + bulkUpdateSuccess(client, repository, registry, [obj1, obj2], { namespace: 'foo-bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('foo-bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + await bulkUpdateSuccess( + client, + repository, + registry, + [obj1, obj2], + undefined, + undefined, + currentSpace.expectedNamespace + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(mockSpacesExt.getSearchableNamespaces).not.toHaveBeenCalled(); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.arrayContaining([ + expect.objectContaining({ + update: expect.objectContaining({ + _id: `${ + currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : '' + }${obj1.type}:${obj1.id}`, + }), + }), + expect.objectContaining({ + doc: expect.objectContaining({ + config: obj1.attributes, + }), + }), + expect.objectContaining({ + update: expect.objectContaining({ + _id: `${obj2.type}:${obj2.id}`, + }), + }), + expect.objectContaining({ + doc: expect.objectContaining({ + multiNamespaceType: obj2.attributes, + }), + }), + ]), + }), + { maxRetries: 0 } + ); + }); + }); + + describe('#bulkResolve', () => { + afterEach(() => { + mockInternalBulkResolve.mockReset(); + }); + + test(`throws error if options.namespace is specified`, async () => { + await expect(repository.bulkResolve([], { namespace: 'foo-bar' })).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('foo-bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + mockInternalBulkResolve.mockResolvedValue({ + resolved_objects: [ + { + saved_object: { type: 'mock', id: 'mock-object', attributes: {}, references: [] }, + outcome: 'exactMatch', + }, + { + type: 'obj-type', + id: 'obj-id-2', + error: SavedObjectsErrorHelpers.createGenericNotFoundError('obj-type', 'obj-id-2'), + }, + ], + }); + const objects = [ + { type: 'obj-type', id: 'obj-id-1' }, + { type: 'obj-type', id: 'obj-id-2' }, + ]; + await repository.bulkResolve(objects); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(mockSpacesExt.getSearchableNamespaces).not.toHaveBeenCalled(); + expect(mockInternalBulkResolve).toHaveBeenCalledTimes(1); + expect(mockInternalBulkResolve).toHaveBeenCalledWith( + expect.objectContaining({ + options: { + namespace: currentSpace.expectedNamespace + ? `${currentSpace.expectedNamespace}` + : undefined, + }, + }) + ); + }); + }); + + describe('#find', () => { + test(`supplements internal parameters with options.type and options.namespaces`, async () => { + const type = 'index-pattern'; + const spaceOverride = 'ns-4'; + await findSuccess(client, repository, { type, namespaces: [spaceOverride] }); + expect(mockSpacesExt.getSearchableNamespaces).toBeCalledTimes(1); + expect(mockSpacesExt.getSearchableNamespaces).toBeCalledWith([spaceOverride]); + expect(mockGetSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + namespaces: [spaceOverride], + type: [type], + }) + ); + }); + + test(`propagates options.namespaces: ['*']`, async () => { + const type = 'index-pattern'; + await findSuccess(client, repository, { type, namespaces: ['*'] }); + expect(mockSpacesExt.getSearchableNamespaces).toBeCalledTimes(1); + expect(mockSpacesExt.getSearchableNamespaces).toBeCalledWith(['*']); + }); + + test(`supplements options with the current namespace`, async () => { + const type = 'index-pattern'; + await findSuccess(client, repository, { type }); + expect(mockSpacesExt.getSearchableNamespaces).toBeCalledTimes(1); + expect(mockSpacesExt.getSearchableNamespaces).toBeCalledWith(undefined); // will resolve current space + }); + }); + + describe('#bulkDelete', () => { + beforeEach(() => { + mockDeleteLegacyUrlAliases.mockClear(); + mockDeleteLegacyUrlAliases.mockResolvedValue(); + }); + + const obj1: SavedObjectsBulkUpdateObject = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + }; + const obj2: SavedObjectsBulkUpdateObject = { + type: MULTI_NAMESPACE_TYPE, + id: 'logstash-*', + attributes: { title: 'Test Two' }, + }; + const testObjs = [obj1, obj2]; + const options = { + force: true, + }; + const internalOptions = { + mockMGetResponseObjects: [ + { + ...obj1, + initialNamespaces: undefined, + }, + { + ...obj2, + initialNamespaces: [currentSpace.id, 'NS-1', 'NS-2'], + }, + ], + }; + + test(`throws error if options.namespace is specified`, async () => { + await expect( + bulkDeleteSuccess(client, repository, registry, testObjs, { namespace: 'foo-bar' }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED) + ); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('foo-bar'); + }); + + test(`supplements internal parameters with the current namespace`, async () => { + await bulkDeleteSuccess(client, repository, registry, testObjs, options, internalOptions); + expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1); + expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined); + expect(mockSpacesExt.getSearchableNamespaces).not.toHaveBeenCalled(); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.arrayContaining([ + expect.objectContaining({ + delete: expect.objectContaining({ + _id: `${ + currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : '' + }${obj1.type}:${obj1.id}`, + }), + }), + expect.objectContaining({ + delete: expect.objectContaining({ + _id: `${obj2.type}:${obj2.id}`, + }), + }), + ]), + }), + { maxRetries: 0 } + ); + }); + }); + }); + }); + + describe(`with security extension`, () => { + beforeEach(() => { + pointInTimeFinderMock.mockClear(); + client = elasticsearchClientMock.createElasticsearchClient(); + migrator = kibanaMigratorMock.create(); + documentMigrator.prepareMigrations(); + migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate); + migrator.runMigrations = jest.fn().mockResolvedValue([{ status: 'skipped' }]); + logger = loggerMock.create(); + // create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation + serializer = createSpySerializer(registry); + // create a mock extensions + mockSpacesExt = savedObjectsExtensionsMock.createSpacesExtension(); + mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension(); + mockGetCurrentTime.mockReturnValue(mockTimestamp); + mockGetSearchDsl.mockClear(); + repository = instantiateRepository(); + mockSpacesExt.getSearchableNamespaces.mockImplementation( + (namespaces: string[] | undefined): Promise => { + if (!namespaces) { + return Promise.resolve([] as string[]); + } else if (!namespaces.length) { + return Promise.resolve(namespaces); + } + if (namespaces?.includes('*')) { + return Promise.resolve(availableSpaces.map((space) => space.id)); + } else { + return Promise.resolve( + namespaces?.filter((namespace) => + availableSpaces.some((space) => space.id === namespace) + ) + ); + } + } + ); + }); + + describe(`#find`, () => { + test(`returns empty result if user is unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + const type = 'index-pattern'; + const spaceOverride = 'ns-4'; + const generatedResults = generateIndexPatternSearchResults(spaceOverride); + client.search.mockResponseOnce(generatedResults); + const result = await repository.find({ type, namespaces: [spaceOverride] }); + expect(result).toEqual(expect.objectContaining({ total: 0 })); + }); + }); + }); +}); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.mock.ts index 9ca156605d638..a9c1871e2488e 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.mock.ts @@ -24,6 +24,7 @@ jest.mock('./collect_multi_namespace_references', () => ({ export const mockInternalBulkResolve = jest.fn() as jest.MockedFunction; jest.mock('./internal_bulk_resolve', () => ({ + ...jest.requireActual('./internal_bulk_resolve'), internalBulkResolve: mockInternalBulkResolve, })); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts index 8c0690bfdf4fd..e8365293991fe 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts @@ -22,7 +22,7 @@ import { import type { Payload } from '@hapi/boom'; import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { schema } from '@kbn/config-schema'; + import type { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-common'; import type { SavedObjectsBaseOptions, @@ -41,7 +41,6 @@ import type { SavedObjectsDeleteOptions, SavedObjectsOpenPointInTimeOptions, SavedObjectsResolveResponse, - SavedObjectsUpdateOptions, SavedObjectsCollectMultiNamespaceReferencesObject, SavedObjectsCollectMultiNamespaceReferencesResponse, SavedObjectsUpdateObjectsSpacesObject, @@ -50,12 +49,9 @@ import type { SavedObjectsBulkDeleteOptions, } from '@kbn/core-saved-objects-api-server'; import type { - SavedObjectsType, SavedObjectsRawDoc, SavedObjectsRawDocSource, SavedObjectUnsanitizedDoc, - SavedObjectsMappingProperties, - SavedObjectsTypeMappingDefinition, } from '@kbn/core-saved-objects-server'; import { SavedObjectsErrorHelpers, @@ -65,7 +61,6 @@ import { SavedObjectsRepository } from './repository'; import { PointInTimeFinder } from './point_in_time_finder'; import { loggerMock } from '@kbn/logging-mocks'; import { - SavedObjectTypeRegistry, SavedObjectsSerializer, encodeHitVersion, LEGACY_URL_ALIAS_TYPE, @@ -74,322 +69,106 @@ import { kibanaMigratorMock } from '../mocks'; import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import * as esKuery from '@kbn/es-query'; import { errors as EsErrors } from '@elastic/elasticsearch'; -import { InternalBulkResolveError } from './internal_bulk_resolve'; +import type { InternalBulkResolveError } from './internal_bulk_resolve'; + +import { + KIBANA_VERSION, + CUSTOM_INDEX_TYPE, + NAMESPACE_AGNOSTIC_TYPE, + MULTI_NAMESPACE_TYPE, + MULTI_NAMESPACE_ISOLATED_TYPE, + HIDDEN_TYPE, + mockVersionProps, + mockTimestampFields, + mockTimestamp, + mappings, + mockVersion, + bulkGetSuccess, + createRegistry, + createDocumentMigrator, + getMockGetResponse, + getMockMgetResponse, + type TypeIdTuple, + createSpySerializer, + bulkCreateSuccess, + bulkUpdateSuccess, + getMockBulkCreateResponse, + bulkGet, + getMockBulkUpdateResponse, + updateSuccess, + mockUpdateResponse, + expectErrorResult, + expectErrorInvalidType, + expectErrorNotFound, + expectErrorConflict, + expectError, + generateIndexPatternSearchResults, + findSuccess, + deleteSuccess, + removeReferencesToSuccess, + checkConflicts, + checkConflictsSuccess, + getSuccess, + createBadRequestErrorPayload, + createUnsupportedTypeErrorPayload, + createConflictErrorPayload, + createGenericNotFoundErrorPayload, + expectCreateResult, + expectUpdateResult, + mockTimestampFieldsWithCreated, + getMockEsBulkDeleteResponse, + bulkDeleteSuccess, + createBulkDeleteSuccessStatus, +} from '../test_helpers/repository.test.common'; const { nodeTypes } = esKuery; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. -interface TypeIdTuple { - id: string; - type: string; -} - interface ExpectedErrorResult { type: string; id: string; error: Record; } -type ErrorPayload = Error & Payload; - -const createBadRequestError = (reason?: string) => - SavedObjectsErrorHelpers.createBadRequestError(reason).output.payload as ErrorPayload; -const createConflictError = (type: string, id: string, reason?: string) => - SavedObjectsErrorHelpers.createConflictError(type, id, reason).output.payload as ErrorPayload; -const createGenericNotFoundError = (type: string | null = null, id: string | null = null) => - SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload as ErrorPayload; -const createUnsupportedTypeError = (type: string) => - SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload as ErrorPayload; - describe('SavedObjectsRepository', () => { let client: ReturnType; - let savedObjectsRepository: SavedObjectsRepository; + let repository: SavedObjectsRepository; let migrator: ReturnType; let logger: ReturnType; let serializer: jest.Mocked; - const mockTimestamp = '2017-08-14T15:49:14.886Z'; - const mockTimestampFields = { updated_at: mockTimestamp }; - const mockTimestampFieldsWithCreated = { updated_at: mockTimestamp, created_at: mockTimestamp }; - const mockVersionProps = { _seq_no: 1, _primary_term: 1 }; - const mockVersion = encodeHitVersion(mockVersionProps); - - const KIBANA_VERSION = '2.0.0'; - const CUSTOM_INDEX_TYPE = 'customIndex'; - /** This type has namespaceType: 'agnostic'. */ - const NAMESPACE_AGNOSTIC_TYPE = 'globalType'; - /** - * This type has namespaceType: 'multiple'. - * - * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is shareable across - * namespaces. - **/ - const MULTI_NAMESPACE_TYPE = 'multiNamespaceType'; - /** - * This type has namespaceType: 'multiple-isolated'. - * - * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is NOT shareable - * across namespaces. This distinction only matters when using the `collectMultiNamespaceReferences` or `updateObjectsSpaces` APIs, or - * when using the `initialNamespaces` argument with the `create` and `bulkCreate` APIs. Those allow you to define or change what - * namespaces an object exists in. - * - * In a nutshell, this type is more restrictive than `MULTI_NAMESPACE_TYPE`, so we use `MULTI_NAMESPACE_ISOLATED_TYPE` for any test cases - * where `MULTI_NAMESPACE_TYPE` would also satisfy the test case. - **/ - const MULTI_NAMESPACE_ISOLATED_TYPE = 'multiNamespaceIsolatedType'; - /** This type has namespaceType: 'multiple', and it uses a custom index. */ - const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'multiNamespaceTypeCustomIndex'; - const HIDDEN_TYPE = 'hiddenType'; - - const mappings: SavedObjectsTypeMappingDefinition = { - properties: { - config: { - properties: { - otherField: { - type: 'keyword', - }, - }, - }, - 'index-pattern': { - properties: { - someField: { - type: 'keyword', - }, - }, - }, - dashboard: { - properties: { - otherField: { - type: 'keyword', - }, - }, - }, - [CUSTOM_INDEX_TYPE]: { - properties: { - otherField: { - type: 'keyword', - }, - }, - }, - [NAMESPACE_AGNOSTIC_TYPE]: { - properties: { - yetAnotherField: { - type: 'keyword', - }, - }, - }, - [MULTI_NAMESPACE_TYPE]: { - properties: { - evenYetAnotherField: { - type: 'keyword', - }, - }, - }, - [MULTI_NAMESPACE_ISOLATED_TYPE]: { - properties: { - evenYetAnotherField: { - type: 'keyword', - }, - }, - }, - [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]: { - properties: { - evenYetAnotherField: { - type: 'keyword', - }, - }, - }, - [HIDDEN_TYPE]: { - properties: { - someField: { - type: 'keyword', - }, - }, - }, - }, - }; - - const createType = (type: string, parts: Partial = {}): SavedObjectsType => ({ - name: type, - hidden: false, - namespaceType: 'single', - mappings: { - properties: mappings.properties[type].properties! as SavedObjectsMappingProperties, - }, - migrations: { '1.1.1': (doc) => doc }, - ...parts, - }); - - const registry = new SavedObjectTypeRegistry(); - registry.registerType(createType('config')); - registry.registerType(createType('index-pattern')); - registry.registerType( - createType('dashboard', { - schemas: { - '8.0.0-testing': schema.object({ - title: schema.maybe(schema.string()), - otherField: schema.maybe(schema.string()), - }), - }, - }) - ); - registry.registerType(createType(CUSTOM_INDEX_TYPE, { indexPattern: 'custom' })); - registry.registerType(createType(NAMESPACE_AGNOSTIC_TYPE, { namespaceType: 'agnostic' })); - registry.registerType(createType(MULTI_NAMESPACE_TYPE, { namespaceType: 'multiple' })); - registry.registerType( - createType(MULTI_NAMESPACE_ISOLATED_TYPE, { namespaceType: 'multiple-isolated' }) - ); - registry.registerType( - createType(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, { - namespaceType: 'multiple', - indexPattern: 'custom', - }) - ); - registry.registerType( - createType(HIDDEN_TYPE, { - hidden: true, - namespaceType: 'agnostic', - }) - ); - - const getMockGetResponse = ( - { - type, - id, - references, - namespace: objectNamespace, - originId, - }: { - type: string; - id: string; - namespace?: string; - originId?: string; - references?: SavedObjectReference[]; - }, - namespace?: string | string[] - ) => { - let namespaces; - if (objectNamespace) { - namespaces = [objectNamespace]; - } else if (namespace) { - namespaces = Array.isArray(namespace) ? namespace : [namespace]; - } else { - namespaces = ['default']; - } - const namespaceId = namespaces[0] === 'default' ? undefined : namespaces[0]; - - return { - // NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these - found: true, - _id: `${ - registry.isSingleNamespace(type) && namespaceId ? `${namespaceId}:` : '' - }${type}:${id}`, - ...mockVersionProps, - _source: { - ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), - ...(registry.isMultiNamespace(type) && { namespaces }), - ...(originId && { originId }), - type, - [type]: { title: 'Testing' }, - references, - specialProperty: 'specialValue', - ...mockTimestampFields, - } as SavedObjectsRawDocSource, - } as estypes.GetResponse; - }; + const registry = createRegistry(); + const documentMigrator = createDocumentMigrator(registry); - const getMockMgetResponse = ( - objects: Array, - namespace?: string - ) => - ({ - docs: objects.map((obj) => - obj.found === false ? obj : getMockGetResponse(obj, obj.initialNamespaces ?? namespace) - ), - } as estypes.MgetResponse); - - expect.extend({ - toBeDocumentWithoutError(received, type, id) { - if (received.type === type && received.id === id && !received.error) { - return { message: () => `expected type and id not to match without error`, pass: true }; - } else { - return { message: () => `expected type and id to match without error`, pass: false }; - } - }, - }); const expectSuccess = ({ type, id }: { type: string; id: string }) => { // @ts-expect-error TS is not aware of the extension return expect.toBeDocumentWithoutError(type, id); }; - const expectError = ({ type, id }: { type: string; id: string }) => ({ - type, - id, - error: expect.any(Object), - }); - - const expectErrorResult = ( - { type, id }: TypeIdTuple, - error: Record, - overrides: Record = {} - ): ExpectedErrorResult => ({ - type, - id, - error: { ...error, ...overrides }, - }); - const expectErrorNotFound = (obj: TypeIdTuple, overrides?: Record) => - expectErrorResult(obj, createGenericNotFoundError(obj.type, obj.id), overrides); - const expectErrorConflict = (obj: TypeIdTuple, overrides?: Record) => - expectErrorResult(obj, createConflictError(obj.type, obj.id), overrides); - const expectErrorInvalidType = (obj: TypeIdTuple, overrides?: Record) => - expectErrorResult(obj, createUnsupportedTypeError(obj.type), overrides); - const expectMigrationArgs = (args: unknown, contains = true, n = 1) => { const obj = contains ? expect.objectContaining(args) : expect.not.objectContaining(args); expect(migrator.migrateDocument).toHaveBeenNthCalledWith(n, obj); }; - const createSpySerializer = () => { - const spyInstance = { - isRawSavedObject: jest.fn(), - rawToSavedObject: jest.fn(), - savedObjectToRaw: jest.fn(), - generateRawId: jest.fn(), - generateRawLegacyUrlAliasId: jest.fn(), - trimIdPrefix: jest.fn(), - }; - const realInstance = new SavedObjectsSerializer(registry); - Object.keys(spyInstance).forEach((key) => { - // @ts-expect-error no proper way to do this with typing support - spyInstance[key].mockImplementation((...args) => realInstance[key](...args)); - }); - - return spyInstance as unknown as jest.Mocked; - }; - beforeEach(() => { pointInTimeFinderMock.mockClear(); client = elasticsearchClientMock.createElasticsearchClient(); migrator = kibanaMigratorMock.create(); - migrator.migrateDocument.mockImplementation((doc) => ({ - ...doc, - migrationVersion: { [doc.type]: '1.1.1' }, - coreMigrationVersion: KIBANA_VERSION, - })); - + documentMigrator.prepareMigrations(); + migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate); migrator.runMigrations = jest.fn().mockResolvedValue([{ status: 'skipped' }]); logger = loggerMock.create(); // create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation - serializer = createSpySerializer(); + serializer = createSpySerializer(registry); const allTypes = registry.getAllTypes().map((type) => type.name); const allowedTypes = [...new Set(allTypes.filter((type) => !registry.isHidden(type)))]; // @ts-expect-error must use the private constructor to use the mocked serializer - savedObjectsRepository = new SavedObjectsRepository({ + repository = new SavedObjectsRepository({ index: '.kibana-test', mappings, client, @@ -437,42 +216,6 @@ describe('SavedObjectsRepository', () => { }; const namespace = 'foo-namespace'; - const getMockBulkCreateResponse = ( - objects: SavedObjectsBulkCreateObject[], - namespace?: string - ) => { - return { - errors: false, - took: 1, - items: objects.map(({ type, id, originId, attributes, references, migrationVersion }) => ({ - create: { - // status: 1, - // _index: '.kibana', - _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, - _source: { - [type]: attributes, - type, - namespace, - ...(originId && { originId }), - references, - ...mockTimestampFieldsWithCreated, - migrationVersion: migrationVersion || { [type]: '1.1.1' }, - }, - ...mockVersionProps, - }, - })), - } as unknown as estypes.BulkResponse; - }; - - const bulkCreateSuccess = async ( - objects: SavedObjectsBulkCreateObject[], - options?: SavedObjectsCreateOptions - ) => { - const response = getMockBulkCreateResponse(objects, options?.namespace); - client.bulk.mockResponse(response); - return await savedObjectsRepository.bulkCreate(objects, options); - }; - // bulk create calls have two objects for each source -- the action, and the source const expectClientCallArgsAction = ( objects: Array<{ type: string; id?: string; if_primary_term?: string; if_seq_no?: string }>, @@ -519,28 +262,15 @@ describe('SavedObjectsRepository', () => { }), ]; - const expectSuccessResult = (obj: { - type: string; - namespace?: string; - namespaces?: string[]; - }) => ({ - ...obj, - migrationVersion: { [obj.type]: '1.1.1' }, - coreMigrationVersion: KIBANA_VERSION, - version: mockVersion, - namespaces: obj.namespaces ?? [obj.namespace ?? 'default'], - ...mockTimestampFieldsWithCreated, - }); - describe('client calls', () => { it(`should use the ES bulk action by default`, async () => { - await bulkCreateSuccess([obj1, obj2]); + await bulkCreateSuccess(client, repository, [obj1, obj2]); expect(client.bulk).toHaveBeenCalledTimes(1); }); it(`should use the preflightCheckForCreate action before bulk action for any types that are multi-namespace, when id is defined`, async () => { const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; - await bulkCreateSuccess(objects); + await bulkCreateSuccess(client, repository, objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1); expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( @@ -559,23 +289,25 @@ describe('SavedObjectsRepository', () => { it(`should use the ES create method if ID is undefined and overwrite=true`, async () => { const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); - await bulkCreateSuccess(objects, { overwrite: true }); + await bulkCreateSuccess(client, repository, objects, { overwrite: true }); expectClientCallArgsAction(objects, { method: 'create' }); }); it(`should use the ES create method if ID is undefined and overwrite=false`, async () => { const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); - await bulkCreateSuccess(objects); + await bulkCreateSuccess(client, repository, objects); expectClientCallArgsAction(objects, { method: 'create' }); }); it(`should use the ES index method if ID is defined and overwrite=true`, async () => { - await bulkCreateSuccess([obj1, obj2], { overwrite: true }); + await bulkCreateSuccess(client, repository, [obj1, obj2], { overwrite: true }); expectClientCallArgsAction([obj1, obj2], { method: 'index' }); }); it(`should use the ES index method with version if ID and version are defined and overwrite=true`, async () => { await bulkCreateSuccess( + client, + repository, [ { ...obj1, @@ -596,12 +328,12 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES create method if ID is defined and overwrite=false`, async () => { - await bulkCreateSuccess([obj1, obj2]); + await bulkCreateSuccess(client, repository, [obj1, obj2]); expectClientCallArgsAction([obj1, obj2], { method: 'create' }); }); it(`formats the ES request`, async () => { - await bulkCreateSuccess([obj1, obj2]); + await bulkCreateSuccess(client, repository, [obj1, obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), @@ -611,7 +343,7 @@ describe('SavedObjectsRepository', () => { describe('originId', () => { it(`returns error if originId is set for non-multi-namespace type`, async () => { - const result = await savedObjectsRepository.bulkCreate([ + const result = await repository.bulkCreate([ { ...obj1, originId: 'some-originId' }, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE, originId: 'some-originId' }, ]); @@ -632,7 +364,7 @@ describe('SavedObjectsRepository', () => { { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; - await bulkCreateSuccess(objects); + await bulkCreateSuccess(client, repository, objects); const expected = expect.not.objectContaining({ originId: expect.anything() }); const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -659,7 +391,7 @@ describe('SavedObjectsRepository', () => { { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: 'some-originId' }, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: 'some-originId' }, ]; - await bulkCreateSuccess(objects); + await bulkCreateSuccess(client, repository, objects); const expected = expect.objectContaining({ originId: 'some-originId' }); const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -674,7 +406,7 @@ describe('SavedObjectsRepository', () => { { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: undefined }, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: undefined }, ]; - await bulkCreateSuccess(objects); + await bulkCreateSuccess(client, repository, objects); const expected = expect.not.objectContaining({ originId: expect.anything() }); const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -688,7 +420,7 @@ describe('SavedObjectsRepository', () => { { ...obj1, type: MULTI_NAMESPACE_TYPE }, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; - await bulkCreateSuccess(objects); + await bulkCreateSuccess(client, repository, objects); const expected = expect.objectContaining({ originId: 'existing-originId' }); const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -700,7 +432,7 @@ describe('SavedObjectsRepository', () => { }); it(`adds namespace to request body for any types that are single-namespace`, async () => { - await bulkCreateSuccess([obj1, obj2], { namespace }); + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); const expected = expect.objectContaining({ namespace }); const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -710,7 +442,7 @@ describe('SavedObjectsRepository', () => { }); it(`normalizes options.namespace from 'default' to undefined`, async () => { - await bulkCreateSuccess([obj1, obj2], { namespace: 'default' }); + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace: 'default' }); const expected = expect.not.objectContaining({ namespace: 'default' }); const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -724,7 +456,7 @@ describe('SavedObjectsRepository', () => { { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; - await bulkCreateSuccess(objects, { namespace }); + await bulkCreateSuccess(client, repository, objects, { namespace }); const expected = expect.not.objectContaining({ namespace: expect.anything() }); const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -745,7 +477,7 @@ describe('SavedObjectsRepository', () => { existingDocument: { _id: o2.id!, _source: { namespaces: ['*'], type: o2.type } }, // second object does have an existing document to overwrite }, ]); - await bulkCreateSuccess(objects, { namespace, overwrite: true }); + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); const expected1 = expect.objectContaining({ namespaces: [namespace ?? 'default'] }); const expected2 = expect.objectContaining({ namespaces: ['*'] }); const body = [expect.any(Object), expected1, expect.any(Object), expected2]; @@ -782,7 +514,7 @@ describe('SavedObjectsRepository', () => { }, }, ]); - await bulkCreateSuccess(objects, { namespace, overwrite: true }); + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); const body = [ { index: expect.objectContaining({ _id: `${ns2}:dashboard:${o1.id}` }) }, expect.objectContaining({ namespace: ns2 }), @@ -818,7 +550,7 @@ describe('SavedObjectsRepository', () => { it(`normalizes initialNamespaces from 'default' to undefined`, async () => { const test = async (namespace?: string) => { const objects = [{ ...obj1, type: 'dashboard', initialNamespaces: ['default'] }]; - await bulkCreateSuccess(objects, { namespace, overwrite: true }); + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); const body = [ { index: expect.objectContaining({ _id: `dashboard:${obj1.id}` }) }, expect.not.objectContaining({ namespace: 'default' }), @@ -836,7 +568,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { const test = async (namespace?: string) => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; - await bulkCreateSuccess(objects, { namespace, overwrite: true }); + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); const expected = expect.not.objectContaining({ namespaces: expect.anything() }); const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -850,7 +582,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to a refresh setting of wait_for`, async () => { - await bulkCreateSuccess([obj1, obj2]); + await bulkCreateSuccess(client, repository, [obj1, obj2]); expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ refresh: 'wait_for' }), expect.anything() @@ -858,7 +590,7 @@ describe('SavedObjectsRepository', () => { }); it(`should use default index`, async () => { - await bulkCreateSuccess([obj1, obj2]); + await bulkCreateSuccess(client, repository, [obj1, obj2]); expectClientCallArgsAction([obj1, obj2], { method: 'create', _index: '.kibana-test_8.0.0-testing', @@ -866,7 +598,11 @@ describe('SavedObjectsRepository', () => { }); it(`should use custom index`, async () => { - await bulkCreateSuccess([obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE }))); + await bulkCreateSuccess( + client, + repository, + [obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE })) + ); expectClientCallArgsAction([obj1, obj2], { method: 'create', _index: 'custom_8.0.0-testing', @@ -875,13 +611,13 @@ describe('SavedObjectsRepository', () => { it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type: string, id: string = '') => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) - await bulkCreateSuccess([obj1, obj2], { namespace }); + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - await bulkCreateSuccess([obj1, obj2]); + await bulkCreateSuccess(client, repository, [obj1, obj2]); expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); }); @@ -891,7 +627,7 @@ describe('SavedObjectsRepository', () => { { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; - await bulkCreateSuccess(objects, { namespace }); + await bulkCreateSuccess(client, repository, objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); }); }); @@ -925,7 +661,7 @@ describe('SavedObjectsRepository', () => { client.bulk.mockResponseOnce(response); const objects = [obj1, obj, obj2]; - const result = await savedObjectsRepository.bulkCreate(objects); + const result = await repository.bulkCreate(objects); expect(client.bulk).toHaveBeenCalled(); const objCall = isBulkError ? expectObjArgs(obj) : []; const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; @@ -940,8 +676,8 @@ describe('SavedObjectsRepository', () => { it(`throws when options.namespace is '*'`, async () => { await expect( - savedObjectsRepository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + repository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); }); it(`returns error when initialNamespaces is used with a space-agnostic object`, async () => { @@ -951,7 +687,9 @@ describe('SavedObjectsRepository', () => { undefined, expectErrorResult( obj, - createBadRequestError('"initialNamespaces" cannot be used on space-agnostic types') + createBadRequestErrorPayload( + '"initialNamespaces" cannot be used on space-agnostic types' + ) ) ); }); @@ -963,7 +701,7 @@ describe('SavedObjectsRepository', () => { undefined, expectErrorResult( obj, - createBadRequestError('"initialNamespaces" must be a non-empty array of strings') + createBadRequestErrorPayload('"initialNamespaces" must be a non-empty array of strings') ) ); }); @@ -976,7 +714,7 @@ describe('SavedObjectsRepository', () => { undefined, expectErrorResult( obj, - createBadRequestError( + createBadRequestErrorPayload( '"initialNamespaces" can only specify a single space when used with space-isolated types' ) ) @@ -1026,7 +764,7 @@ describe('SavedObjectsRepository', () => { client.bulk.mockResponseOnce(bulkResponse); const options = { overwrite: true }; - const result = await savedObjectsRepository.bulkCreate(objects, options); + const result = await repository.bulkCreate(objects, options); expect(mockPreflightCheckForCreate).toHaveBeenCalled(); expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( expect.objectContaining({ @@ -1066,7 +804,7 @@ describe('SavedObjectsRepository', () => { const response = getMockBulkCreateResponse([obj3]); client.bulk.mockResponseOnce(response); - const result = await savedObjectsRepository.bulkCreate([ + const result = await repository.bulkCreate([ obj3, // @ts-expect-error - Title should be a string and is intentionally malformed for testing { ...obj3, id: 'three-again', attributes: { title: 123 } }, @@ -1089,8 +827,10 @@ describe('SavedObjectsRepository', () => { it(`migrates the docs and serializes the migrated docs`, async () => { migrator.migrateDocument.mockImplementation(mockMigrateDocument); const modifiedObj1 = { ...obj1, coreMigrationVersion: '8.0.0' }; - await bulkCreateSuccess([modifiedObj1, obj2]); + + await bulkCreateSuccess(client, repository, [modifiedObj1, obj2]); const docs = [modifiedObj1, obj2].map((x) => ({ ...x, ...mockTimestampFieldsWithCreated })); + expectMigrationArgs(docs[0], true, 1); expectMigrationArgs(docs[1], true, 2); @@ -1100,13 +840,13 @@ describe('SavedObjectsRepository', () => { }); it(`adds namespace to body when providing namespace for single-namespace type`, async () => { - await bulkCreateSuccess([obj1, obj2], { namespace }); + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); expectMigrationArgs({ namespace }, true, 1); expectMigrationArgs({ namespace }, true, 2); }); it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { - await bulkCreateSuccess([obj1, obj2]); + await bulkCreateSuccess(client, repository, [obj1, obj2]); expectMigrationArgs({ namespace: expect.anything() }, false, 1); expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); @@ -1116,7 +856,7 @@ describe('SavedObjectsRepository', () => { { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; - await bulkCreateSuccess(objects, { namespace }); + await bulkCreateSuccess(client, repository, objects, { namespace }); expectMigrationArgs({ namespace: expect.anything() }, false, 1); expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); @@ -1126,7 +866,7 @@ describe('SavedObjectsRepository', () => { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, })); - await bulkCreateSuccess(objects, { namespace }); + await bulkCreateSuccess(client, repository, objects, { namespace }); expectMigrationArgs({ namespaces: [namespace] }, true, 1); expectMigrationArgs({ namespaces: [namespace] }, true, 2); }); @@ -1136,14 +876,14 @@ describe('SavedObjectsRepository', () => { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, })); - await bulkCreateSuccess(objects); + await bulkCreateSuccess(client, repository, objects); expectMigrationArgs({ namespaces: ['default'] }, true, 1); expectMigrationArgs({ namespaces: ['default'] }, true, 2); }); it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; - await bulkCreateSuccess(objects); + await bulkCreateSuccess(client, repository, objects); expectMigrationArgs({ namespaces: expect.anything() }, false, 1); expectMigrationArgs({ namespaces: expect.anything() }, false, 2); }); @@ -1151,9 +891,9 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it(`formats the ES response`, async () => { - const result = await bulkCreateSuccess([obj1, obj2]); + const result = await bulkCreateSuccess(client, repository, [obj1, obj2]); expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectSuccessResult(x)), + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), }); }); @@ -1168,10 +908,10 @@ describe('SavedObjectsRepository', () => { const objects = [obj1, obj, obj2]; const response = getMockBulkCreateResponse([obj1, obj2]); client.bulk.mockResponseOnce(response); - const result = await savedObjectsRepository.bulkCreate(objects); + const result = await repository.bulkCreate(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(result).toEqual({ - saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], + saved_objects: [expectCreateResult(obj1), expectError(obj), expectCreateResult(obj2)], }); }); @@ -1186,7 +926,7 @@ describe('SavedObjectsRepository', () => { client.bulk.mockResponseOnce(response); // Bulk create one object with id unspecified, and one with id specified - const result = await savedObjectsRepository.bulkCreate([{ ...obj1, id: undefined }, obj2], { + const result = await repository.bulkCreate([{ ...obj1, id: undefined }, obj2], { namespace, }); @@ -1246,22 +986,6 @@ describe('SavedObjectsRepository', () => { }; const namespace = 'foo-namespace'; - const bulkGet = async ( - objects: SavedObjectsBulkGetObject[], - options?: SavedObjectsBaseOptions - ) => - savedObjectsRepository.bulkGet( - objects.map(({ type, id, namespaces }) => ({ type, id, namespaces })), // bulkGet only uses type, id, and optionally namespaces - options - ); - const bulkGetSuccess = async (objects: SavedObject[], options?: SavedObjectsBaseOptions) => { - const response = getMockMgetResponse(objects, options?.namespace); - client.mget.mockResponseOnce(response); - const result = await bulkGet(objects, options); - expect(client.mget).toHaveBeenCalledTimes(1); - return result; - }; - const _expectClientCallArgs = ( objects: TypeIdTuple[], { @@ -1287,33 +1011,33 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type: string, id: string) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) - await bulkGetSuccess([obj1, obj2], { namespace }); + await bulkGetSuccess(client, repository, registry, [obj1, obj2], { namespace }); _expectClientCallArgs([obj1, obj2], { getId }); }); it(`prepends namespace to the id when providing namespaces for single-namespace type`, async () => { const getId = (type: string, id: string) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) const objects = [obj1, obj2].map((obj) => ({ ...obj, namespaces: [namespace] })); - await bulkGetSuccess(objects, { namespace: 'some-other-ns' }); + await bulkGetSuccess(client, repository, registry, objects, { namespace: 'some-other-ns' }); _expectClientCallArgs([obj1, obj2], { getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - await bulkGetSuccess([obj1, obj2]); + await bulkGetSuccess(client, repository, registry, [obj1, obj2]); _expectClientCallArgs([obj1, obj2], { getId }); }); it(`normalizes options.namespace from 'default' to undefined`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - await bulkGetSuccess([obj1, obj2], { namespace: 'default' }); + await bulkGetSuccess(client, repository, registry, [obj1, obj2], { namespace: 'default' }); _expectClientCallArgs([obj1, obj2], { getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) let objects = [obj1, obj2].map((obj) => ({ ...obj, type: NAMESPACE_AGNOSTIC_TYPE })); - await bulkGetSuccess(objects, { namespace }); + await bulkGetSuccess(client, repository, registry, objects, { namespace }); _expectClientCallArgs(objects, { getId }); client.mget.mockClear(); @@ -1321,7 +1045,7 @@ describe('SavedObjectsRepository', () => { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, })); - await bulkGetSuccess(objects, { namespace }); + await bulkGetSuccess(client, repository, registry, objects, { namespace }); _expectClientCallArgs(objects, { getId }); }); }); @@ -1337,16 +1061,16 @@ describe('SavedObjectsRepository', () => { // mock the bulk error for only the second object mockGetBulkOperationError.mockReturnValueOnce(undefined); mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload); - response = getMockMgetResponse([obj1, obj, obj2]); + response = getMockMgetResponse(registry, [obj1, obj, obj2]); } else { - response = getMockMgetResponse([obj1, obj2]); + response = getMockMgetResponse(registry, [obj1, obj2]); } client.mget.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); const objects = [obj1, obj, obj2]; - const result = await bulkGet(objects); + const result = await bulkGet(repository, objects); expect(client.mget).toHaveBeenCalled(); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectedErrorResult, expectSuccess(obj2)], @@ -1356,8 +1080,8 @@ describe('SavedObjectsRepository', () => { it(`throws when options.namespace is '*'`, async () => { const obj = { type: 'dashboard', id: 'three' }; await expect( - savedObjectsRepository.bulkGet([obj], { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + repository.bulkGet([obj], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); }); it(`returns error when namespaces is used with a space-agnostic object`, async () => { @@ -1367,7 +1091,7 @@ describe('SavedObjectsRepository', () => { false, expectErrorResult( obj, - createBadRequestError('"namespaces" cannot be used on space-agnostic types') + createBadRequestErrorPayload('"namespaces" cannot be used on space-agnostic types') ) ); }); @@ -1380,7 +1104,7 @@ describe('SavedObjectsRepository', () => { false, expectErrorResult( obj, - createBadRequestError( + createBadRequestErrorPayload( '"namespaces" can only specify a single space when used with space-isolated types' ) ) @@ -1449,17 +1173,17 @@ describe('SavedObjectsRepository', () => { }); it(`returns early for empty objects argument`, async () => { - const result = await bulkGet([]); + const result = await bulkGet(repository, []); expect(result).toEqual({ saved_objects: [] }); expect(client.mget).not.toHaveBeenCalled(); }); it(`formats the ES response`, async () => { - const response = getMockMgetResponse([obj1, obj2]); + const response = getMockMgetResponse(registry, [obj1, obj2]); client.mget.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - const result = await bulkGet([obj1, obj2]); + const result = await bulkGet(repository, [obj1, obj2]); expect(client.mget).toHaveBeenCalledTimes(1); expect(result).toEqual({ saved_objects: [ @@ -1476,7 +1200,7 @@ describe('SavedObjectsRepository', () => { }); it(`handles a mix of successful gets and errors`, async () => { - const response = getMockMgetResponse([obj1, obj2]); + const response = getMockMgetResponse(registry, [obj1, obj2]); client.mget.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -1486,7 +1210,7 @@ describe('SavedObjectsRepository', () => { attributes: {}, references: [], }; - const result = await bulkGet([obj1, obj, obj2]); + const result = await bulkGet(repository, [obj1, obj, obj2]); expect(client.mget).toHaveBeenCalledTimes(1); expect(result).toEqual({ saved_objects: [ @@ -1510,7 +1234,7 @@ describe('SavedObjectsRepository', () => { attributes: {}, references: [], }; - const result = await bulkGetSuccess([obj1, obj]); + const { result } = await bulkGetSuccess(client, repository, registry, [obj1, obj]); expect(result).toEqual({ saved_objects: [ expect.objectContaining({ namespaces: ['default'] }), @@ -1545,7 +1269,7 @@ describe('SavedObjectsRepository', () => { { type: 'obj-type', id: 'obj-id-1' }, { type: 'obj-type', id: 'obj-id-2' }, ]; - await expect(savedObjectsRepository.bulkResolve(objects)).resolves.toEqual({ + await expect(repository.bulkResolve(objects)).resolves.toEqual({ resolved_objects: [ { saved_object: { type: 'mock', id: 'mock-object', attributes: {}, references: [] }, @@ -1573,7 +1297,7 @@ describe('SavedObjectsRepository', () => { const error = new Error('Oh no!'); mockInternalBulkResolve.mockRejectedValue(error); - await expect(savedObjectsRepository.resolve('some-type', 'some-id')).rejects.toEqual(error); + await expect(repository.resolve('some-type', 'some-id')).rejects.toEqual(error); }); }); @@ -1592,47 +1316,6 @@ describe('SavedObjectsRepository', () => { const originId = 'some-origin-id'; const namespace = 'foo-namespace'; - const getMockBulkUpdateResponse = ( - objects: TypeIdTuple[], - options?: SavedObjectsBulkUpdateOptions, - includeOriginId?: boolean - ) => - ({ - items: objects.map(({ type, id }) => ({ - update: { - _id: `${ - registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : '' - }${type}:${id}`, - ...mockVersionProps, - get: { - _source: { - // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the - // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. - ...(includeOriginId && { originId }), - }, - }, - result: 'updated', - }, - })), - } as estypes.BulkResponse); - - const bulkUpdateSuccess = async ( - objects: SavedObjectsBulkUpdateObject[], - options?: SavedObjectsBulkUpdateOptions, - includeOriginId?: boolean - ) => { - const multiNamespaceObjects = objects.filter(({ type }) => registry.isMultiNamespace(type)); - if (multiNamespaceObjects?.length) { - const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); - client.mget.mockResponseOnce(response); - } - const response = getMockBulkUpdateResponse(objects, options, includeOriginId); - client.bulk.mockResponseOnce(response); - const result = await savedObjectsRepository.bulkUpdate(objects, options); - expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); - return result; - }; - // bulk create calls have two objects for each source -- the action, and the source const expectClientCallArgsAction = ( objects: TypeIdTuple[], @@ -1677,13 +1360,13 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES bulk action by default`, async () => { - await bulkUpdateSuccess([obj1, obj2]); + await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); expect(client.bulk).toHaveBeenCalled(); }); it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; - await bulkUpdateSuccess(objects); + await bulkUpdateSuccess(client, repository, registry, objects); expect(client.bulk).toHaveBeenCalled(); expect(client.mget).toHaveBeenCalled(); @@ -1697,7 +1380,7 @@ describe('SavedObjectsRepository', () => { }); it(`formats the ES request`, async () => { - await bulkUpdateSuccess([obj1, obj2]); + await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), @@ -1707,7 +1390,7 @@ describe('SavedObjectsRepository', () => { it(`formats the ES request for any types that are multi-namespace`, async () => { const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; - await bulkUpdateSuccess([obj1, _obj2]); + await bulkUpdateSuccess(client, repository, registry, [obj1, _obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), @@ -1717,12 +1400,12 @@ describe('SavedObjectsRepository', () => { it(`doesnt call Elasticsearch if there are no valid objects to update`, async () => { const objects = [obj1, obj2].map((x) => ({ ...x, type: 'unknownType' })); - await savedObjectsRepository.bulkUpdate(objects); + await repository.bulkUpdate(objects); expect(client.bulk).toHaveBeenCalledTimes(0); }); it(`defaults to no references`, async () => { - await bulkUpdateSuccess([obj1, obj2]); + await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -1734,7 +1417,7 @@ describe('SavedObjectsRepository', () => { it(`accepts custom references array`, async () => { const test = async (references: SavedObjectReference[]) => { const objects = [obj1, obj2].map((obj) => ({ ...obj, references })); - await bulkUpdateSuccess(objects); + await bulkUpdateSuccess(client, repository, registry, objects); const expected = { doc: expect.objectContaining({ references }) }; const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -1750,9 +1433,8 @@ describe('SavedObjectsRepository', () => { it(`doesn't accept custom references if not an array`, async () => { const test = async (references: unknown) => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, references })); - // @ts-expect-error references is unknown - await bulkUpdateSuccess(objects); + const objects = [obj1, obj2]; // .map((obj) => ({ ...obj })); + await bulkUpdateSuccess(client, repository, registry, objects); const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; const body = [expect.any(Object), expected, expect.any(Object), expected]; expect(client.bulk).toHaveBeenCalledWith( @@ -1768,7 +1450,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to a refresh setting of wait_for`, async () => { - await bulkUpdateSuccess([obj1, obj2]); + await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ refresh: 'wait_for' }), expect.anything() @@ -1777,7 +1459,7 @@ describe('SavedObjectsRepository', () => { it(`defaults to no version for types that are not multi-namespace`, async () => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; - await bulkUpdateSuccess(objects); + await bulkUpdateSuccess(client, repository, registry, objects); expectClientCallArgsAction(objects, { method: 'update' }); }); @@ -1788,19 +1470,19 @@ describe('SavedObjectsRepository', () => { { ...obj1, version }, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, version }, ]; - await bulkUpdateSuccess(objects); + await bulkUpdateSuccess(client, repository, registry, objects); const overrides = { if_seq_no: 100, if_primary_term: 200 }; expectClientCallArgsAction(objects, { method: 'update', overrides }); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type: string, id: string) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) - await bulkUpdateSuccess([obj1, obj2], { namespace }); + await bulkUpdateSuccess(client, repository, registry, [obj1, obj2], { namespace }); expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); jest.clearAllMocks(); // test again with object namespace string that supersedes the operation's namespace ID - await bulkUpdateSuccess([ + await bulkUpdateSuccess(client, repository, registry, [ { ...obj1, namespace }, { ...obj2, namespace }, ]); @@ -1809,12 +1491,15 @@ describe('SavedObjectsRepository', () => { it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - await bulkUpdateSuccess([obj1, obj2]); + await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); jest.clearAllMocks(); // test again with object namespace string that supersedes the operation's namespace ID await bulkUpdateSuccess( + client, + repository, + registry, [ { ...obj1, namespace: 'default' }, { ...obj2, namespace: 'default' }, @@ -1826,7 +1511,9 @@ describe('SavedObjectsRepository', () => { it(`normalizes options.namespace from 'default' to undefined`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; - await bulkUpdateSuccess([obj1, obj2], { namespace: 'default' }); + await bulkUpdateSuccess(client, repository, registry, [obj1, obj2], { + namespace: 'default', + }); expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); }); @@ -1835,18 +1522,18 @@ describe('SavedObjectsRepository', () => { const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; - await bulkUpdateSuccess([_obj1], { namespace }); + await bulkUpdateSuccess(client, repository, registry, [_obj1], { namespace }); expectClientCallArgsAction([_obj1], { method: 'update', getId }); client.bulk.mockClear(); - await bulkUpdateSuccess([_obj2], { namespace }); + await bulkUpdateSuccess(client, repository, registry, [_obj2], { namespace }); expectClientCallArgsAction([_obj2], { method: 'update', getId }); jest.clearAllMocks(); // test again with object namespace string that supersedes the operation's namespace ID - await bulkUpdateSuccess([{ ..._obj1, namespace }]); + await bulkUpdateSuccess(client, repository, registry, [{ ..._obj1, namespace }]); expectClientCallArgsAction([_obj1], { method: 'update', getId }); client.bulk.mockClear(); - await bulkUpdateSuccess([{ ..._obj2, namespace }]); + await bulkUpdateSuccess(client, repository, registry, [{ ..._obj2, namespace }]); expectClientCallArgsAction([_obj2], { method: 'update', getId }); }); }); @@ -1868,7 +1555,7 @@ describe('SavedObjectsRepository', () => { expectedErrorResult: ExpectedErrorResult ) => { const objects = [obj1, obj, obj2]; - const mockResponse = getMockBulkUpdateResponse(objects); + const mockResponse = getMockBulkUpdateResponse(registry, objects); if (isBulkError) { // mock the bulk error for only the second object mockGetBulkOperationError.mockReturnValueOnce(undefined); @@ -1876,7 +1563,7 @@ describe('SavedObjectsRepository', () => { } client.bulk.mockResponseOnce(mockResponse); - const result = await savedObjectsRepository.bulkUpdate(objects); + const result = await repository.bulkUpdate(objects); expect(client.bulk).toHaveBeenCalled(); const objCall = isBulkError ? expectObjArgs(obj) : []; const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; @@ -1897,10 +1584,10 @@ describe('SavedObjectsRepository', () => { ) => { client.mget.mockResponseOnce(mgetResponse, { statusCode: mgetOptions?.statusCode }); - const bulkResponse = getMockBulkUpdateResponse([obj1, obj2], { namespace }); + const bulkResponse = getMockBulkUpdateResponse(registry, [obj1, obj2], { namespace }); client.bulk.mockResponseOnce(bulkResponse); - const result = await savedObjectsRepository.bulkUpdate([obj1, _obj, obj2], options); + const result = await repository.bulkUpdate([obj1, _obj, obj2], options); expect(client.bulk).toHaveBeenCalled(); expect(client.mget).toHaveBeenCalled(); const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; @@ -1916,8 +1603,8 @@ describe('SavedObjectsRepository', () => { it(`throws when options.namespace is '*'`, async () => { await expect( - savedObjectsRepository.bulkUpdate([obj], { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + repository.bulkUpdate([obj], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); }); it(`returns error when type is invalid`, async () => { @@ -1935,26 +1622,27 @@ describe('SavedObjectsRepository', () => { await bulkUpdateError( _obj, false, - expectErrorResult(obj, createBadRequestError('"namespace" cannot be "*"')) + expectErrorResult(obj, createBadRequestErrorPayload('"namespace" cannot be "*"')) ); }); it(`returns error when ES is unable to find the document (mget)`, async () => { const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false }; - const mgetResponse = getMockMgetResponse([_obj]); + const mgetResponse = getMockMgetResponse(registry, [_obj]); await bulkUpdateMultiError([obj1, _obj, obj2], undefined, mgetResponse); }); it(`returns error when ES is unable to find the index (mget)`, async () => { const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; - await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, {} as estypes.MgetResponse, { + const mgetResponse = getMockMgetResponse(registry, [_obj]); + await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse, { statusCode: 404, }); }); it(`returns error when there is a conflict with an existing multi-namespace saved object (mget)`, async () => { const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; - const mgetResponse = getMockMgetResponse([_obj], 'bar-namespace'); + const mgetResponse = getMockMgetResponse(registry, [_obj], 'bar-namespace'); await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); @@ -1969,33 +1657,18 @@ describe('SavedObjectsRepository', () => { }); describe('returns', () => { - const expectSuccessResult = ({ - type, - id, - attributes, - references, - }: SavedObjectsBulkUpdateObject) => ({ - type, - id, - attributes, - references, - version: mockVersion, - namespaces: ['default'], - ...mockTimestampFields, - }); - it(`formats the ES response`, async () => { - const response = await bulkUpdateSuccess([obj1, obj2]); + const response = await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); expect(response).toEqual({ - saved_objects: [obj1, obj2].map(expectSuccessResult), + saved_objects: [obj1, obj2].map(expectUpdateResult), }); }); it(`includes references`, async () => { const objects = [obj1, obj2].map((obj) => ({ ...obj, references })); - const response = await bulkUpdateSuccess(objects); + const response = await bulkUpdateSuccess(client, repository, registry, objects); expect(response).toEqual({ - saved_objects: objects.map(expectSuccessResult), + saved_objects: objects.map(expectUpdateResult), }); }); @@ -2006,13 +1679,13 @@ describe('SavedObjectsRepository', () => { attributes: {}, }; const objects = [obj1, obj, obj2]; - const mockResponse = getMockBulkUpdateResponse(objects); + const mockResponse = getMockBulkUpdateResponse(registry, objects); client.bulk.mockResponseOnce(mockResponse); - const result = await savedObjectsRepository.bulkUpdate(objects); + const result = await repository.bulkUpdate(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(result).toEqual({ - saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], + saved_objects: [expectUpdateResult(obj1), expectError(obj), expectUpdateResult(obj2)], }); }); @@ -2022,7 +1695,7 @@ describe('SavedObjectsRepository', () => { id: 'three', attributes: {}, }; - const result = await bulkUpdateSuccess([obj1, obj]); + const result = await bulkUpdateSuccess(client, repository, registry, [obj1, obj]); expect(result).toEqual({ saved_objects: [ expect.objectContaining({ namespaces: expect.any(Array) }), @@ -2037,7 +1710,14 @@ describe('SavedObjectsRepository', () => { id: 'three', attributes: {}, }; - const result = await bulkUpdateSuccess([obj1, obj], {}, true); + const result = await bulkUpdateSuccess( + client, + repository, + registry, + [obj1, obj], + {}, + originId + ); expect(result).toEqual({ saved_objects: [ expect.objectContaining({ originId }), @@ -2063,50 +1743,6 @@ describe('SavedObjectsRepository', () => { const createNamespaceAwareGetId = (type: string, id: string) => `${registry.isSingleNamespace(type) && namespace ? `${namespace}:` : ''}${type}:${id}`; - const getMockEsBulkDeleteResponse = ( - objects: TypeIdTuple[], - options?: SavedObjectsBulkDeleteOptions - ) => - ({ - items: objects.map(({ type, id }) => ({ - // es response returns more fields than what we're interested in. - delete: { - _id: `${ - registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : '' - }${type}:${id}`, - ...mockVersionProps, - result: 'deleted', - }, - })), - } as estypes.BulkResponse); - - const repositoryBulkDeleteSuccess = async ( - objects: SavedObjectsBulkDeleteObject[] = [], - options?: SavedObjectsBulkDeleteOptions, - internalOptions: { - mockMGetResponseWithObject?: { initialNamespaces: string[]; type: string; id: string }; - } = {} - ) => { - const multiNamespaceObjects = objects.filter(({ type }) => { - return registry.isMultiNamespace(type); - }); - - const { mockMGetResponseWithObject } = internalOptions; - if (multiNamespaceObjects.length > 0) { - const mockedMGetResponse = mockMGetResponseWithObject - ? getMockMgetResponse([mockMGetResponseWithObject], options?.namespace) - : getMockMgetResponse(multiNamespaceObjects, options?.namespace); - client.mget.mockResponseOnce(mockedMGetResponse); - } - const mockedEsBulkDeleteResponse = getMockEsBulkDeleteResponse(objects, options); - - client.bulk.mockResponseOnce(mockedEsBulkDeleteResponse); - const result = await savedObjectsRepository.bulkDelete(objects, options); - - expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); - return result; - }; - // bulk delete calls only has one object for each source -- the action const expectClientCallBulkDeleteArgsAction = ( objects: TypeIdTuple[], @@ -2151,13 +1787,7 @@ describe('SavedObjectsRepository', () => { type, id, success: false, - error: error ?? createBadRequestError(), - }); - - const createBulkDeleteSuccessStatus = ({ type, id }: { type: string; id: string }) => ({ - type, - id, - success: true, + error: error ?? SavedObjectsErrorHelpers.createBadRequestError(), }); // mocks a combination of success, error results for hidden and unknown object object types. @@ -2167,14 +1797,14 @@ describe('SavedObjectsRepository', () => { expectedErrorResult: ExpectedErrorResult ) => { const objects = [obj1, obj, obj2]; - const mockedBulkDeleteResponse = getMockEsBulkDeleteResponse(objects); + const mockedBulkDeleteResponse = getMockEsBulkDeleteResponse(registry, objects); if (isBulkError) { mockGetBulkOperationError.mockReturnValueOnce(undefined); mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload); } client.bulk.mockResponseOnce(mockedBulkDeleteResponse); - const result = await savedObjectsRepository.bulkDelete(objects); + const result = await repository.bulkDelete(objects); expect(client.bulk).toHaveBeenCalled(); expect(result).toEqual({ statuses: [ @@ -2225,10 +1855,10 @@ describe('SavedObjectsRepository', () => { // mock the response for the not found doc client.mget.mockResponseOnce(mgetResponse, { statusCode: mgetOptions?.statusCode }); // get a mocked response for the valid docs - const bulkResponse = getMockEsBulkDeleteResponse([obj1, obj2], { namespace }); + const bulkResponse = getMockEsBulkDeleteResponse(registry, [obj1, obj2], { namespace }); client.bulk.mockResponseOnce(bulkResponse); - const result = await savedObjectsRepository.bulkDelete([obj1, _obj, obj2], options); + const result = await repository.bulkDelete([obj1, _obj, obj2], options); expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledTimes(1); @@ -2249,13 +1879,13 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES bulk action by default`, async () => { - await repositoryBulkDeleteSuccess([obj1, obj2]); + await bulkDeleteSuccess(client, repository, registry, [obj1, obj2]); expect(client.bulk).toHaveBeenCalled(); }); it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; - await repositoryBulkDeleteSuccess(objects); + await bulkDeleteSuccess(client, repository, registry, objects); expect(client.bulk).toHaveBeenCalled(); expect(client.mget).toHaveBeenCalled(); @@ -2270,25 +1900,25 @@ describe('SavedObjectsRepository', () => { it(`should not use the ES bulk action when there are no valid documents to delete`, async () => { const objects = [obj1, obj2].map((x) => ({ ...x, type: 'unknownType' })); - await savedObjectsRepository.bulkDelete(objects); + await repository.bulkDelete(objects); expect(client.bulk).toHaveBeenCalledTimes(0); }); it(`formats the ES request`, async () => { const getId = createNamespaceAwareGetId; - await repositoryBulkDeleteSuccess([obj1, obj2], { namespace }); + await bulkDeleteSuccess(client, repository, registry, [obj1, obj2], { namespace }); expectClientCallBulkDeleteArgsAction([obj1, obj2], { method: 'delete', getId }); }); it(`formats the ES request for any types that are multi-namespace`, async () => { const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const getId = createNamespaceAwareGetId; - await repositoryBulkDeleteSuccess([obj1, _obj2], { namespace }); + await bulkDeleteSuccess(client, repository, registry, [obj1, _obj2], { namespace }); expectClientCallBulkDeleteArgsAction([obj1, _obj2], { method: 'delete', getId }); }); it(`defaults to a refresh setting of wait_for`, async () => { - await repositoryBulkDeleteSuccess([obj1, obj2]); + await bulkDeleteSuccess(client, repository, registry, [obj1, obj2]); expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ refresh: 'wait_for' }), expect.anything() @@ -2297,25 +1927,27 @@ describe('SavedObjectsRepository', () => { it(`does not include the version of the existing document when not using a multi-namespace type`, async () => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; - await repositoryBulkDeleteSuccess(objects); + await bulkDeleteSuccess(client, repository, registry, objects); expectClientCallBulkDeleteArgsAction(objects, { method: 'delete' }); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = createNamespaceAwareGetId; - await repositoryBulkDeleteSuccess([obj1, obj2], { namespace }); + await bulkDeleteSuccess(client, repository, registry, [obj1, obj2], { namespace }); expectClientCallBulkDeleteArgsAction([obj1, obj2], { method: 'delete', getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; - await repositoryBulkDeleteSuccess([obj1, obj2]); + await bulkDeleteSuccess(client, repository, registry, [obj1, obj2]); expectClientCallBulkDeleteArgsAction([obj1, obj2], { method: 'delete', getId }); }); it(`normalizes options.namespace from 'default' to undefined`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; - await repositoryBulkDeleteSuccess([obj1, obj2], { namespace: 'default' }); + await bulkDeleteSuccess(client, repository, registry, [obj1, obj2], { + namespace: 'default', + }); expectClientCallBulkDeleteArgsAction([obj1, obj2], { method: 'delete', getId }); }); @@ -2324,26 +1956,31 @@ describe('SavedObjectsRepository', () => { const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; - await repositoryBulkDeleteSuccess([_obj1, _obj2], { namespace }); + await bulkDeleteSuccess(client, repository, registry, [_obj1, _obj2], { namespace }); expectClientCallBulkDeleteArgsAction([_obj1, _obj2], { method: 'delete', getId }); }); }); describe('legacy URL aliases', () => { it(`doesn't delete legacy URL aliases for single-namespace object types`, async () => { - await repositoryBulkDeleteSuccess([obj1, obj2]); + await bulkDeleteSuccess(client, repository, registry, [obj1, obj2]); expect(mockDeleteLegacyUrlAliases).not.toHaveBeenCalled(); }); it(`deletes legacy URL aliases for multi-namespace object types (all spaces)`, async () => { const testObject = { ...obj1, type: MULTI_NAMESPACE_TYPE }; const internalOptions = { - mockMGetResponseWithObject: { - ...testObject, - initialNamespaces: [ALL_NAMESPACES_STRING], - }, + mockMGetResponseObjects: [ + { + ...testObject, + initialNamespaces: [ALL_NAMESPACES_STRING], + }, + ], }; - await repositoryBulkDeleteSuccess( + await bulkDeleteSuccess( + client, + repository, + registry, [testObject], { namespace, force: true }, internalOptions @@ -2361,13 +1998,22 @@ describe('SavedObjectsRepository', () => { it(`deletes legacy URL aliases for multi-namespace object types (specific space)`, async () => { const testObject = { ...obj1, type: MULTI_NAMESPACE_TYPE }; const internalOptions = { - mockMGetResponseWithObject: { - ...testObject, - initialNamespaces: [namespace], - }, + mockMGetResponseObjects: [ + { + ...testObject, + initialNamespaces: [namespace], + }, + ], }; // specifically test against the current namespace - await repositoryBulkDeleteSuccess([testObject], { namespace }, internalOptions); + await bulkDeleteSuccess( + client, + repository, + registry, + [testObject], + { namespace }, + internalOptions + ); expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith( expect.objectContaining({ type: MULTI_NAMESPACE_TYPE, @@ -2382,13 +2028,18 @@ describe('SavedObjectsRepository', () => { const testObject = { ...obj1, type: MULTI_NAMESPACE_TYPE }; const initialTestObjectNamespaces = [namespace, 'bar-namespace']; const internalOptions = { - mockMGetResponseWithObject: { - ...testObject, - initialNamespaces: initialTestObjectNamespaces, - }, + mockMGetResponseObjects: [ + { + ...testObject, + initialNamespaces: initialTestObjectNamespaces, + }, + ], }; // specifically test against named spaces ('*' is handled specifically, this assures we also take care of named spaces) - await repositoryBulkDeleteSuccess( + await bulkDeleteSuccess( + client, + repository, + registry, [testObject], { namespace, force: true }, internalOptions @@ -2408,15 +2059,17 @@ describe('SavedObjectsRepository', () => { client.mget.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( - getMockMgetResponse([testObject], namespace) + getMockMgetResponse(registry, [testObject], namespace) ) ); - const mockedBulkResponse = getMockEsBulkDeleteResponse([testObject], { namespace }); + const mockedBulkResponse = getMockEsBulkDeleteResponse(registry, [testObject], { + namespace, + }); client.bulk.mockResolvedValueOnce(mockedBulkResponse); mockDeleteLegacyUrlAliases.mockRejectedValueOnce(new Error('Oh no!')); - await savedObjectsRepository.bulkDelete([testObject], { namespace }); + await repository.bulkDelete([testObject], { namespace }); expect(client.mget).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledTimes(1); @@ -2429,20 +2082,22 @@ describe('SavedObjectsRepository', () => { describe('errors', () => { it(`throws an error when options.namespace is '*'`, async () => { await expect( - savedObjectsRepository.bulkDelete([obj1], { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + repository.bulkDelete([obj1], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError( + SavedObjectsErrorHelpers.createBadRequestError('"options.namespace" cannot be "*"') + ); }); it(`throws an error when client bulk response is not defined`, async () => { client.mget.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( - getMockMgetResponse([obj1], namespace) + getMockMgetResponse(registry, [obj1], namespace) ) ); const mockedBulkResponse = undefined; // we have to cast here to test the assumption we always get a response. client.bulk.mockResponseOnce(mockedBulkResponse as unknown as estypes.BulkResponse); - await expect(savedObjectsRepository.bulkDelete([obj1], { namespace })).rejects.toThrowError( + await expect(repository.bulkDelete([obj1], { namespace })).rejects.toThrowError( 'Unexpected error in bulkDelete saved objects: bulkDeleteResponse is undefined' ); }); @@ -2463,7 +2118,7 @@ describe('SavedObjectsRepository', () => { it(`returns an error when ES is unable to find the document during mget`, async () => { const notFoundObj = { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false }; - const mgetResponse = getMockMgetResponse([notFoundObj], namespace); + const mgetResponse = getMockMgetResponse(registry, [notFoundObj], namespace); await bulkDeleteMultiNamespaceError([obj1, notFoundObj, obj2], { namespace }, mgetResponse); }); @@ -2485,19 +2140,24 @@ describe('SavedObjectsRepository', () => { id: 'three', namespace: 'bar-namespace', }; - const mgetResponse = getMockMgetResponse([obj], namespace); + const mgetResponse = getMockMgetResponse(registry, [obj], namespace); await bulkDeleteMultiNamespaceError([obj1, obj, obj2], { namespace }, mgetResponse); }); it(`returns an error when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => { const testObject = { ...obj1, type: MULTI_NAMESPACE_TYPE }; const internalOptions = { - mockMGetResponseWithObject: { - ...testObject, - initialNamespaces: [namespace, 'bar-namespace'], - }, + mockMGetResponseObjects: [ + { + ...testObject, + initialNamespaces: [namespace, 'bar-namespace'], + }, + ], }; - const result = await repositoryBulkDeleteSuccess( + const result = await bulkDeleteSuccess( + client, + repository, + registry, [testObject], { namespace }, internalOptions @@ -2505,8 +2165,8 @@ describe('SavedObjectsRepository', () => { expect(result.statuses[0]).toStrictEqual( createBulkDeleteFailStatus({ ...testObject, - error: createBadRequestError( - 'Unable to delete saved object that exists in multiple namespaces, use the "force" option to delete it anyway' + error: createBadRequestErrorPayload( + 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ), }) ); @@ -2515,12 +2175,17 @@ describe('SavedObjectsRepository', () => { it(`returns an error when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => { const testObject = { ...obj1, type: ALL_NAMESPACES_STRING }; const internalOptions = { - mockMGetResponseWithObject: { - ...testObject, - initialNamespaces: [namespace, 'bar-namespace'], - }, + mockMGetResponseObjects: [ + { + ...testObject, + initialNamespaces: [namespace, 'bar-namespace'], + }, + ], }; - const result = await repositoryBulkDeleteSuccess( + const result = await bulkDeleteSuccess( + client, + repository, + registry, [testObject], { namespace }, internalOptions @@ -2528,7 +2193,7 @@ describe('SavedObjectsRepository', () => { expect(result.statuses[0]).toStrictEqual( createBulkDeleteFailStatus({ ...testObject, - error: createBadRequestError("Unsupported saved object type: '*'"), + error: createBadRequestErrorPayload("Unsupported saved object type: '*'"), }) ); }); @@ -2536,12 +2201,14 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it(`returns early for empty objects argument`, async () => { - await savedObjectsRepository.bulkDelete([], { namespace }); + await repository.bulkDelete([], { namespace }); expect(client.bulk).toHaveBeenCalledTimes(0); }); it(`formats the ES response`, async () => { - const response = await repositoryBulkDeleteSuccess([obj1, obj2], { namespace }); + const response = await bulkDeleteSuccess(client, repository, registry, [obj1, obj2], { + namespace, + }); expect(response).toEqual({ statuses: [obj1, obj2].map(createBulkDeleteSuccessStatus), }); @@ -2569,24 +2236,6 @@ describe('SavedObjectsRepository', () => { const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' }; const namespace = 'foo-namespace'; - const checkConflicts = async (objects: TypeIdTuple[], options?: SavedObjectsBaseOptions) => - savedObjectsRepository.checkConflicts( - objects.map(({ type, id }) => ({ type, id })), // checkConflicts only uses type and id - options - ); - const checkConflictsSuccess = async ( - objects: TypeIdTuple[], - options?: SavedObjectsBaseOptions - ) => { - const response = getMockMgetResponse(objects, options?.namespace); - client.mget.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise(response) - ); - const result = await checkConflicts(objects, options); - expect(client.mget).toHaveBeenCalledTimes(1); - return result; - }; - const _expectClientCallArgs = ( objects: TypeIdTuple[], { @@ -2611,32 +2260,34 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`doesn't make a cluster call if the objects array is empty`, async () => { - await checkConflicts([]); + await checkConflicts(repository, []); expect(client.mget).not.toHaveBeenCalled(); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type: string, id: string) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) - await checkConflictsSuccess([obj1, obj2], { namespace }); + await checkConflictsSuccess(client, repository, registry, [obj1, obj2], { namespace }); _expectClientCallArgs([obj1, obj2], { getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - await checkConflictsSuccess([obj1, obj2]); + await checkConflictsSuccess(client, repository, registry, [obj1, obj2]); _expectClientCallArgs([obj1, obj2], { getId }); }); it(`normalizes options.namespace from 'default' to undefined`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - await checkConflictsSuccess([obj1, obj2], { namespace: 'default' }); + await checkConflictsSuccess(client, repository, registry, [obj1, obj2], { + namespace: 'default', + }); _expectClientCallArgs([obj1, obj2], { getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) // obj3 is multi-namespace, and obj6 is namespace-agnostic - await checkConflictsSuccess([obj3, obj6], { namespace }); + await checkConflictsSuccess(client, repository, registry, [obj3, obj6], { namespace }); _expectClientCallArgs([obj3, obj6], { getId }); }); }); @@ -2644,8 +2295,8 @@ describe('SavedObjectsRepository', () => { describe('errors', () => { it(`throws when options.namespace is '*'`, async () => { await expect( - savedObjectsRepository.checkConflicts([obj1], { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + repository.checkConflicts([obj1], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); }); }); @@ -2656,12 +2307,12 @@ describe('SavedObjectsRepository', () => { const objects = [unknownTypeObj, hiddenTypeObj, obj1, obj2, obj3, obj4, obj5, obj6, obj7]; const response = { docs: [ - getMockGetResponse(obj1), + getMockGetResponse(registry, obj1), { found: false }, - getMockGetResponse(obj3), - getMockGetResponse({ ...obj4, namespace: 'bar-namespace' }), + getMockGetResponse(registry, obj3), + getMockGetResponse(registry, { ...obj4, namespace: 'bar-namespace' }), { found: false }, - getMockGetResponse(obj6), + getMockGetResponse(registry, obj6), { found: false }, ], } as estypes.MgetResponse; @@ -2669,24 +2320,24 @@ describe('SavedObjectsRepository', () => { elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - const result = await checkConflicts(objects); + const result = await checkConflicts(repository, objects); expect(client.mget).toHaveBeenCalledTimes(1); expect(result).toEqual({ errors: [ - { ...unknownTypeObj, error: createUnsupportedTypeError(unknownTypeObj.type) }, - { ...hiddenTypeObj, error: createUnsupportedTypeError(hiddenTypeObj.type) }, - { ...obj1, error: createConflictError(obj1.type, obj1.id) }, + { ...unknownTypeObj, error: createUnsupportedTypeErrorPayload(unknownTypeObj.type) }, + { ...hiddenTypeObj, error: createUnsupportedTypeErrorPayload(hiddenTypeObj.type) }, + { ...obj1, error: createConflictErrorPayload(obj1.type, obj1.id) }, // obj2 was not found so it does not result in a conflict error - { ...obj3, error: createConflictError(obj3.type, obj3.id) }, + { ...obj3, error: createConflictErrorPayload(obj3.type, obj3.id) }, { ...obj4, error: { - ...createConflictError(obj4.type, obj4.id), + ...createConflictErrorPayload(obj4.type, obj4.id), metadata: { isNotOverwritable: true }, }, }, // obj5 was not found so it does not result in a conflict error - { ...obj6, error: createConflictError(obj6.type, obj6.id) }, + { ...obj6, error: createConflictErrorPayload(obj6.type, obj6.id) }, // obj7 was not found so it does not result in a conflict error ], }); @@ -2727,7 +2378,7 @@ describe('SavedObjectsRepository', () => { attributes: T, options?: SavedObjectsCreateOptions ) => { - return await savedObjectsRepository.create(type, attributes, options); + return await repository.create(type, attributes, options); }; describe('client calls', () => { @@ -2827,9 +2478,11 @@ describe('SavedObjectsRepository', () => { for (const objType of [type, NAMESPACE_AGNOSTIC_TYPE]) { it(`throws an error if originId is set for non-multi-namespace type`, async () => { await expect( - savedObjectsRepository.create(objType, attributes, { originId: 'some-originId' }) + repository.create(objType, attributes, { originId: 'some-originId' }) ).rejects.toThrowError( - createBadRequestError('"originId" can only be set for multi-namespace object types') + createBadRequestErrorPayload( + '"originId" can only be set for multi-namespace object types' + ) ); }); } @@ -3027,13 +2680,13 @@ describe('SavedObjectsRepository', () => { const ns2 = 'bar-namespace'; const ns3 = 'baz-namespace'; // first object does not get passed in to preflightCheckForCreate at all - await savedObjectsRepository.create('dashboard', attributes, { + await repository.create('dashboard', attributes, { id, namespace, initialNamespaces: [ns2], }); // second object does not have an existing document to overwrite - await savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + await repository.create(MULTI_NAMESPACE_TYPE, attributes, { id, namespace, initialNamespaces: [ns2, ns3], @@ -3048,7 +2701,7 @@ describe('SavedObjectsRepository', () => { }, // third object does have an existing document to overwrite }, ]); - await savedObjectsRepository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { + await repository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace, initialNamespaces: [ns2], @@ -3099,7 +2752,7 @@ describe('SavedObjectsRepository', () => { }); it(`normalizes initialNamespaces from 'default' to undefined`, async () => { - await savedObjectsRepository.create('dashboard', attributes, { + await repository.create('dashboard', attributes, { id, namespace, initialNamespaces: ['default'], @@ -3134,28 +2787,28 @@ describe('SavedObjectsRepository', () => { describe('errors', () => { it(`throws when options.initialNamespaces is used with a space-agnostic object`, async () => { await expect( - savedObjectsRepository.create(NAMESPACE_AGNOSTIC_TYPE, attributes, { + repository.create(NAMESPACE_AGNOSTIC_TYPE, attributes, { initialNamespaces: [namespace], }) ).rejects.toThrowError( - createBadRequestError('"initialNamespaces" cannot be used on space-agnostic types') + createBadRequestErrorPayload('"initialNamespaces" cannot be used on space-agnostic types') ); }); it(`throws when options.initialNamespaces is empty`, async () => { await expect( - savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) + repository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) ).rejects.toThrowError( - createBadRequestError('"initialNamespaces" must be a non-empty array of strings') + createBadRequestErrorPayload('"initialNamespaces" must be a non-empty array of strings') ); }); it(`throws when options.initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { const doTest = async (objType: string, initialNamespaces?: string[]) => { await expect( - savedObjectsRepository.create(objType, attributes, { initialNamespaces }) + repository.create(objType, attributes, { initialNamespaces }) ).rejects.toThrowError( - createBadRequestError( + createBadRequestErrorPayload( '"initialNamespaces" can only specify a single space when used with space-isolated types' ) ); @@ -3168,27 +2821,27 @@ describe('SavedObjectsRepository', () => { it(`throws when options.namespace is '*'`, async () => { await expect( - savedObjectsRepository.create(type, attributes, { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + repository.create(type, attributes, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); }); it(`throws when type is invalid`, async () => { - await expect(savedObjectsRepository.create('unknownType', attributes)).rejects.toThrowError( - createUnsupportedTypeError('unknownType') + await expect(repository.create('unknownType', attributes)).rejects.toThrowError( + createUnsupportedTypeErrorPayload('unknownType') ); expect(client.create).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { - await expect(savedObjectsRepository.create(HIDDEN_TYPE, attributes)).rejects.toThrowError( - createUnsupportedTypeError(HIDDEN_TYPE) + await expect(repository.create(HIDDEN_TYPE, attributes)).rejects.toThrowError( + createUnsupportedTypeErrorPayload(HIDDEN_TYPE) ); expect(client.create).not.toHaveBeenCalled(); }); it(`throws when schema validation fails`, async () => { await expect( - savedObjectsRepository.create('dashboard', { title: 123 }) + repository.create('dashboard', { title: 123 }) ).rejects.toThrowErrorMatchingInlineSnapshot( `"[attributes.title]: expected value of type [string] but got [number]: Bad Request"` ); @@ -3200,12 +2853,12 @@ describe('SavedObjectsRepository', () => { { type: MULTI_NAMESPACE_ISOLATED_TYPE, id, error: { type: 'unresolvableConflict' } }, // error type and metadata dont matter ]); await expect( - savedObjectsRepository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { + repository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true, namespace, }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); + ).rejects.toThrowError(createConflictErrorPayload(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(mockPreflightCheckForCreate).toHaveBeenCalled(); }); @@ -3309,26 +2962,6 @@ describe('SavedObjectsRepository', () => { const id = 'logstash-*'; const namespace = 'foo-namespace'; - const deleteSuccess = async ( - type: string, - id: string, - options?: SavedObjectsDeleteOptions, - internalOptions: { mockGetResponseValue?: estypes.GetResponse } = {} - ) => { - const { mockGetResponseValue } = internalOptions; - if (registry.isMultiNamespace(type)) { - const mockGetResponse = - mockGetResponseValue ?? getMockGetResponse({ type, id }, options?.namespace); - client.get.mockResponseOnce(mockGetResponse); - } - client.delete.mockResponseOnce({ - result: 'deleted', - } as estypes.DeleteResponse); - const result = await savedObjectsRepository.delete(type, id, options); - expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0); - return result; - }; - beforeEach(() => { mockDeleteLegacyUrlAliases.mockClear(); mockDeleteLegacyUrlAliases.mockResolvedValue(); @@ -3336,19 +2969,19 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES delete action when not using a multi-namespace type`, async () => { - await deleteSuccess(type, id); + await deleteSuccess(client, repository, registry, type, id); expect(client.get).not.toHaveBeenCalled(); expect(client.delete).toHaveBeenCalledTimes(1); }); it(`should use ES get action then delete action when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); + await deleteSuccess(client, repository, registry, MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); expect(client.delete).toHaveBeenCalledTimes(1); }); it(`does not includes the version of the existing document when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); + await deleteSuccess(client, repository, registry, MULTI_NAMESPACE_ISOLATED_TYPE, id); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -3360,7 +2993,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to a refresh setting of wait_for`, async () => { - await deleteSuccess(type, id); + await deleteSuccess(client, repository, registry, type, id); expect(client.delete).toHaveBeenCalledWith( expect.objectContaining({ refresh: 'wait_for' }), expect.anything() @@ -3368,7 +3001,7 @@ describe('SavedObjectsRepository', () => { }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { - await deleteSuccess(type, id, { namespace }); + await deleteSuccess(client, repository, registry, type, id, { namespace }); expect(client.delete).toHaveBeenCalledWith( expect.objectContaining({ id: `${namespace}:${type}:${id}` }), expect.anything() @@ -3376,7 +3009,7 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { - await deleteSuccess(type, id); + await deleteSuccess(client, repository, registry, type, id); expect(client.delete).toHaveBeenCalledWith( expect.objectContaining({ id: `${type}:${id}` }), expect.anything() @@ -3384,7 +3017,7 @@ describe('SavedObjectsRepository', () => { }); it(`normalizes options.namespace from 'default' to undefined`, async () => { - await deleteSuccess(type, id, { namespace: 'default' }); + await deleteSuccess(client, repository, registry, type, id, { namespace: 'default' }); expect(client.delete).toHaveBeenCalledWith( expect.objectContaining({ id: `${type}:${id}` }), expect.anything() @@ -3392,14 +3025,18 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { - await deleteSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); + await deleteSuccess(client, repository, registry, NAMESPACE_AGNOSTIC_TYPE, id, { + namespace, + }); expect(client.delete).toHaveBeenCalledWith( expect.objectContaining({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }), expect.anything() ); client.delete.mockClear(); - await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); + await deleteSuccess(client, repository, registry, MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace, + }); expect(client.delete).toHaveBeenCalledWith( expect.objectContaining({ id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}` }), expect.anything() @@ -3409,7 +3046,7 @@ describe('SavedObjectsRepository', () => { describe('legacy URL aliases', () => { it(`doesn't delete legacy URL aliases for single-namespace object types`, async () => { - await deleteSuccess(type, id, { namespace }); + await deleteSuccess(client, repository, registry, type, id, { namespace }); expect(mockDeleteLegacyUrlAliases).not.toHaveBeenCalled(); }); @@ -3419,11 +3056,20 @@ describe('SavedObjectsRepository', () => { it(`deletes legacy URL aliases for multi-namespace object types (all spaces)`, async () => { const internalOptions = { mockGetResponseValue: getMockGetResponse( + registry, { type: MULTI_NAMESPACE_TYPE, id }, ALL_NAMESPACES_STRING ), }; - await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace, force: true }, internalOptions); + await deleteSuccess( + client, + repository, + registry, + MULTI_NAMESPACE_TYPE, + id, + { namespace, force: true }, + internalOptions + ); expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith( expect.objectContaining({ type: MULTI_NAMESPACE_TYPE, @@ -3435,7 +3081,7 @@ describe('SavedObjectsRepository', () => { }); it(`deletes legacy URL aliases for multi-namespace object types (specific spaces)`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); // this function mocks a preflight response with the given namespace by default + await deleteSuccess(client, repository, registry, MULTI_NAMESPACE_TYPE, id, { namespace }); // this function mocks a preflight response with the given namespace by default expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith( expect.objectContaining({ type: MULTI_NAMESPACE_TYPE, @@ -3449,7 +3095,7 @@ describe('SavedObjectsRepository', () => { it(`logs a message when deleteLegacyUrlAliases returns an error`, async () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise( - getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }) + getMockGetResponse(registry, { type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }) ) ); client.delete.mockResolvedValueOnce( @@ -3458,7 +3104,7 @@ describe('SavedObjectsRepository', () => { } as estypes.DeleteResponse) ); mockDeleteLegacyUrlAliases.mockRejectedValueOnce(new Error('Oh no!')); - await savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); + await repository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.get).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledWith( @@ -3473,15 +3119,15 @@ describe('SavedObjectsRepository', () => { id: string, options?: SavedObjectsDeleteOptions ) => { - await expect(savedObjectsRepository.delete(type, id, options)).rejects.toThrowError( - createGenericNotFoundError(type, id) + await expect(repository.delete(type, id, options)).rejects.toThrowError( + createGenericNotFoundErrorPayload(type, id) ); }; it(`throws when options.namespace is '*'`, async () => { await expect( - savedObjectsRepository.delete(type, id, { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + repository.delete(type, id, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); }); it(`throws when type is invalid`, async () => { @@ -3515,7 +3161,11 @@ describe('SavedObjectsRepository', () => { }); it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); + const response = getMockGetResponse( + registry, + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + namespace + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -3526,13 +3176,17 @@ describe('SavedObjectsRepository', () => { }); it(`throws when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); + const response = getMockGetResponse(registry, { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id, + namespace, + }); response._source!.namespaces = [namespace, 'bar-namespace']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) + repository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -3540,13 +3194,17 @@ describe('SavedObjectsRepository', () => { }); it(`throws when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); + const response = getMockGetResponse(registry, { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id, + namespace, + }); response._source!.namespaces = ['*']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) + repository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -3580,7 +3238,7 @@ describe('SavedObjectsRepository', () => { result: 'something unexpected' as estypes.Result, } as estypes.DeleteResponse) ); - await expect(savedObjectsRepository.delete(type, id)).rejects.toThrowError( + await expect(repository.delete(type, id)).rejects.toThrowError( 'Unexpected Elasticsearch DELETE response' ); expect(client.delete).toHaveBeenCalledTimes(1); @@ -3589,7 +3247,7 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it(`returns an empty object on success`, async () => { - const result = await deleteSuccess(type, id); + const result = await deleteSuccess(client, repository, registry, type, id); expect(result).toEqual({}); }); }); @@ -3618,7 +3276,7 @@ describe('SavedObjectsRepository', () => { options?: SavedObjectsDeleteByNamespaceOptions ) => { client.updateByQuery.mockResponseOnce(mockUpdateResults); - const result = await savedObjectsRepository.deleteByNamespace(namespace, options); + const result = await repository.deleteByNamespace(namespace, options); expect(mockGetSearchDsl).toHaveBeenCalledTimes(1); expect(client.updateByQuery).toHaveBeenCalledTimes(1); return result; @@ -3645,7 +3303,7 @@ describe('SavedObjectsRepository', () => { it(`throws when namespace is not a string or is '*'`, async () => { const test = async (namespace: unknown) => { // @ts-expect-error namespace is unknown - await expect(savedObjectsRepository.deleteByNamespace(namespace)).rejects.toThrowError( + await expect(repository.deleteByNamespace(namespace)).rejects.toThrowError( `namespace is required, and must be a string` ); expect(client.updateByQuery).not.toHaveBeenCalled(); @@ -3685,24 +3343,16 @@ describe('SavedObjectsRepository', () => { const type = 'type'; const id = 'id'; const defaultOptions = {}; - const updatedCount = 42; - const removeReferencesToSuccess = async (options = defaultOptions) => { - client.updateByQuery.mockResponseOnce({ - updated: updatedCount, - }); - return await savedObjectsRepository.removeReferencesTo(type, id, options); - }; - describe('client calls', () => { it('should use the ES updateByQuery action', async () => { - await removeReferencesToSuccess(); + await removeReferencesToSuccess(client, repository, type, id); expect(client.updateByQuery).toHaveBeenCalledTimes(1); }); it('uses the correct default `refresh` value', async () => { - await removeReferencesToSuccess(); + await removeReferencesToSuccess(client, repository, type, id); expect(client.updateByQuery).toHaveBeenCalledWith( expect.objectContaining({ refresh: true, @@ -3714,7 +3364,7 @@ describe('SavedObjectsRepository', () => { it('merges output of getSearchDsl into es request body', async () => { const query = { query: 1, aggregations: 2 }; mockGetSearchDsl.mockReturnValue(query); - await removeReferencesToSuccess({ type }); + await removeReferencesToSuccess(client, repository, type, id, { type }); expect(client.updateByQuery).toHaveBeenCalledWith( expect.objectContaining({ @@ -3725,7 +3375,7 @@ describe('SavedObjectsRepository', () => { }); it('should set index to all known SO indices on the request', async () => { - await removeReferencesToSuccess(); + await removeReferencesToSuccess(client, repository, type, id); expect(client.updateByQuery).toHaveBeenCalledWith( expect.objectContaining({ index: ['.kibana-test_8.0.0-testing', 'custom_8.0.0-testing'], @@ -3737,7 +3387,7 @@ describe('SavedObjectsRepository', () => { it('should use the `refresh` option in the request', async () => { const refresh = Symbol(); - await removeReferencesToSuccess({ refresh }); + await removeReferencesToSuccess(client, repository, type, id, { refresh }); expect(client.updateByQuery).toHaveBeenCalledWith( expect.objectContaining({ refresh, @@ -3747,7 +3397,7 @@ describe('SavedObjectsRepository', () => { }); it('should pass the correct parameters to the update script', async () => { - await removeReferencesToSuccess(); + await removeReferencesToSuccess(client, repository, type, id); expect(client.updateByQuery).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ @@ -3766,12 +3416,12 @@ describe('SavedObjectsRepository', () => { describe('search dsl', () => { it(`passes mappings and registry to getSearchDsl`, async () => { - await removeReferencesToSuccess(); + await removeReferencesToSuccess(client, repository, type, id); expect(mockGetSearchDsl).toHaveBeenCalledWith(mappings, registry, expect.anything()); }); it('passes namespace to getSearchDsl', async () => { - await removeReferencesToSuccess({ namespace: 'some-ns' }); + await removeReferencesToSuccess(client, repository, type, id, { namespace: 'some-ns' }); expect(mockGetSearchDsl).toHaveBeenCalledWith( mappings, registry, @@ -3782,7 +3432,7 @@ describe('SavedObjectsRepository', () => { }); it('passes hasReference to getSearchDsl', async () => { - await removeReferencesToSuccess(); + await removeReferencesToSuccess(client, repository, type, id); expect(mockGetSearchDsl).toHaveBeenCalledWith( mappings, registry, @@ -3796,7 +3446,7 @@ describe('SavedObjectsRepository', () => { }); it('passes all known types to getSearchDsl', async () => { - await removeReferencesToSuccess(); + await removeReferencesToSuccess(client, repository, type, id); expect(mockGetSearchDsl).toHaveBeenCalledWith( mappings, registry, @@ -3809,7 +3459,7 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it('returns the updated count from the ES response', async () => { - const response = await removeReferencesToSuccess(); + const response = await removeReferencesToSuccess(client, repository, type, id); expect(response.updated).toBe(updatedCount); }); }); @@ -3824,109 +3474,27 @@ describe('SavedObjectsRepository', () => { ], }); - await expect( - savedObjectsRepository.removeReferencesTo(type, id, defaultOptions) - ).rejects.toThrowError(createConflictError(type, id)); + await expect(repository.removeReferencesTo(type, id, defaultOptions)).rejects.toThrowError( + createConflictErrorPayload(type, id) + ); }); }); }); describe('#find', () => { - const generateSearchResults = (namespace?: string) => { - return { - took: 1, - timed_out: false, - _shards: {} as any, - hits: { - total: 4, - hits: [ - { - _index: '.kibana', - _id: `${namespace ? `${namespace}:` : ''}index-pattern:logstash-*`, - _score: 1, - ...mockVersionProps, - _source: { - namespace, - originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'logstash-*', - timeFieldName: '@timestamp', - notExpandable: true, - }, - }, - }, - { - _index: '.kibana', - _id: `${namespace ? `${namespace}:` : ''}config:6.0.0-alpha1`, - _score: 2, - ...mockVersionProps, - _source: { - namespace, - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8467, - defaultIndex: 'logstash-*', - }, - }, - }, - { - _index: '.kibana', - _id: `${namespace ? `${namespace}:` : ''}index-pattern:stocks-*`, - _score: 3, - ...mockVersionProps, - _source: { - namespace, - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'stocks-*', - timeFieldName: '@timestamp', - notExpandable: true, - }, - }, - }, - { - _index: '.kibana', - _id: `${NAMESPACE_AGNOSTIC_TYPE}:something`, - _score: 4, - ...mockVersionProps, - _source: { - type: NAMESPACE_AGNOSTIC_TYPE, - ...mockTimestampFields, - [NAMESPACE_AGNOSTIC_TYPE]: { - name: 'bar', - }, - }, - }, - ], - }, - } as estypes.SearchResponse; - }; - const type = 'index-pattern'; const namespace = 'foo-namespace'; - const findSuccess = async (options: SavedObjectsFindOptions, namespace?: string) => { - client.search.mockResponseOnce(generateSearchResults(namespace)); - const result = await savedObjectsRepository.find(options); - expect(mockGetSearchDsl).toHaveBeenCalledTimes(1); - expect(client.search).toHaveBeenCalledTimes(1); - return result; - }; - describe('client calls', () => { it(`should use the ES search action`, async () => { - await findSuccess({ type }); + await findSuccess(client, repository, { type }); expect(client.search).toHaveBeenCalledTimes(1); }); it(`merges output of getSearchDsl into es request body`, async () => { const query = { query: 1, aggregations: 2 }; mockGetSearchDsl.mockReturnValue(query); - await findSuccess({ type }); + await findSuccess(client, repository, { type }); expect(client.search).toHaveBeenCalledWith( expect.objectContaining({ @@ -3937,7 +3505,7 @@ describe('SavedObjectsRepository', () => { }); it(`accepts per_page/page`, async () => { - await findSuccess({ type, perPage: 10, page: 6 }); + await findSuccess(client, repository, { type, perPage: 10, page: 6 }); expect(client.search).toHaveBeenCalledWith( expect.objectContaining({ size: 10, @@ -3948,7 +3516,7 @@ describe('SavedObjectsRepository', () => { }); it(`accepts preference`, async () => { - await findSuccess({ type, preference: 'pref' }); + await findSuccess(client, repository, { type, preference: 'pref' }); expect(client.search).toHaveBeenCalledWith( expect.objectContaining({ preference: 'pref', @@ -3958,7 +3526,7 @@ describe('SavedObjectsRepository', () => { }); it(`can filter by fields`, async () => { - await findSuccess({ type, fields: ['title'] }); + await findSuccess(client, repository, { type, fields: ['title'] }); expect(client.search).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ @@ -3982,7 +3550,7 @@ describe('SavedObjectsRepository', () => { }); it(`should set rest_total_hits_as_int to true on a request`, async () => { - await findSuccess({ type }); + await findSuccess(client, repository, { type }); expect(client.search).toHaveBeenCalledWith( expect.objectContaining({ rest_total_hits_as_int: true, @@ -3993,7 +3561,7 @@ describe('SavedObjectsRepository', () => { it(`should not make a client call when attempting to find only invalid or hidden types`, async () => { const test = async (types: string | string[]) => { - await savedObjectsRepository.find({ type: types }); + await repository.find({ type: types }); expect(client.search).not.toHaveBeenCalled(); }; @@ -4006,50 +3574,30 @@ describe('SavedObjectsRepository', () => { describe('errors', () => { it(`throws when type is not defined`, async () => { // @ts-expect-error type should be defined - await expect(savedObjectsRepository.find({})).rejects.toThrowError( + await expect(repository.find({})).rejects.toThrowError( 'options.type must be a string or an array of strings' ); expect(client.search).not.toHaveBeenCalled(); }); it(`throws when namespaces is an empty array`, async () => { - await expect( - savedObjectsRepository.find({ type: 'foo', namespaces: [] }) - ).rejects.toThrowError('options.namespaces cannot be an empty array'); - expect(client.search).not.toHaveBeenCalled(); - }); - - it(`throws when type is not falsy and typeToNamespacesMap is defined`, async () => { - await expect( - savedObjectsRepository.find({ type: 'foo', typeToNamespacesMap: new Map() }) - ).rejects.toThrowError( - 'options.type must be an empty string when options.typeToNamespacesMap is used' + await expect(repository.find({ type: 'foo', namespaces: [] })).rejects.toThrowError( + 'options.namespaces cannot be an empty array' ); expect(client.search).not.toHaveBeenCalled(); }); - it(`throws when type is not an empty array and typeToNamespacesMap is defined`, async () => { - const test = async (args: SavedObjectsFindOptions) => { - await expect(savedObjectsRepository.find(args)).rejects.toThrowError( - 'options.namespaces must be an empty array when options.typeToNamespacesMap is used' - ); - expect(client.search).not.toHaveBeenCalled(); - }; - await test({ type: '', typeToNamespacesMap: new Map() }); - await test({ type: '', namespaces: ['some-ns'], typeToNamespacesMap: new Map() }); - }); - it(`throws when searchFields is defined but not an array`, async () => { await expect( // @ts-expect-error searchFields is an array - savedObjectsRepository.find({ type, searchFields: 'string' }) + repository.find({ type, searchFields: 'string' }) ).rejects.toThrowError('options.searchFields must be an array'); expect(client.search).not.toHaveBeenCalled(); }); it(`throws when fields is defined but not an array`, async () => { // @ts-expect-error fields is an array - await expect(savedObjectsRepository.find({ type, fields: 'string' })).rejects.toThrowError( + await expect(repository.find({ type, fields: 'string' })).rejects.toThrowError( 'options.fields must be an array' ); expect(client.search).not.toHaveBeenCalled(); @@ -4057,7 +3605,7 @@ describe('SavedObjectsRepository', () => { it(`throws when a preference is provided with pit`, async () => { await expect( - savedObjectsRepository.find({ type: 'foo', pit: { id: 'abc123' }, preference: 'hi' }) + repository.find({ type: 'foo', pit: { id: 'abc123' }, preference: 'hi' }) ).rejects.toThrowError('options.preference must be excluded when options.pit is used'); expect(client.search).not.toHaveBeenCalled(); }); @@ -4078,7 +3626,7 @@ describe('SavedObjectsRepository', () => { filter: 'dashboard.attributes.otherField:<', }; - await expect(savedObjectsRepository.find(findOpts)).rejects.toMatchInlineSnapshot(` + await expect(repository.find(findOpts)).rejects.toMatchInlineSnapshot(` [Error: KQLSyntaxError: Expected "(", "{", value, whitespace but "<" found. dashboard.attributes.otherField:< --------------------------------^: Bad Request] @@ -4090,13 +3638,13 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it(`formats the ES response when there is no namespace`, async () => { - const noNamespaceSearchResults = generateSearchResults(); + const noNamespaceSearchResults = generateIndexPatternSearchResults(); client.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(noNamespaceSearchResults) ); const count = noNamespaceSearchResults.hits.hits.length; - const response = await savedObjectsRepository.find({ type }); + const response = await repository.find({ type }); expect(response.total).toBe(count); expect(response.saved_objects).toHaveLength(count); @@ -4117,13 +3665,13 @@ describe('SavedObjectsRepository', () => { }); it(`formats the ES response when there is a namespace`, async () => { - const namespacedSearchResults = generateSearchResults(namespace); + const namespacedSearchResults = generateIndexPatternSearchResults(namespace); client.search.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(namespacedSearchResults) ); const count = namespacedSearchResults.hits.hits.length; - const response = await savedObjectsRepository.find({ type, namespaces: [namespace] }); + const response = await repository.find({ type, namespaces: [namespace] }); expect(response.total).toBe(count); expect(response.saved_objects).toHaveLength(count); @@ -4145,7 +3693,7 @@ describe('SavedObjectsRepository', () => { it(`should return empty results when attempting to find only invalid or hidden types`, async () => { const test = async (types: string | string[]) => { - const result = await savedObjectsRepository.find({ type: types }); + const result = await repository.find({ type: types }); expect(result).toEqual(expect.objectContaining({ saved_objects: [] })); expect(client.search).not.toHaveBeenCalled(); }; @@ -4154,28 +3702,12 @@ describe('SavedObjectsRepository', () => { await test(HIDDEN_TYPE); await test(['unknownType', HIDDEN_TYPE]); }); - - it(`should return empty results when attempting to find only invalid or hidden types using typeToNamespacesMap`, async () => { - const test = async (types: string[]) => { - const result = await savedObjectsRepository.find({ - typeToNamespacesMap: new Map(types.map((x) => [x, undefined])), - type: '', - namespaces: [], - }); - expect(result).toEqual(expect.objectContaining({ saved_objects: [] })); - expect(client.search).not.toHaveBeenCalled(); - }; - - await test(['unknownType']); - await test([HIDDEN_TYPE]); - await test(['unknownType', HIDDEN_TYPE]); - }); }); describe('search dsl', () => { const commonOptions: SavedObjectsFindOptions = { - type: [type], // cannot be used when `typeToNamespacesMap` is present - namespaces: [namespace], // cannot be used when `typeToNamespacesMap` is present + type: [type], + namespaces: [namespace], search: 'foo*', searchFields: ['foo'], sortField: 'name', @@ -4192,47 +3724,17 @@ describe('SavedObjectsRepository', () => { }; it(`passes mappings, registry, and search options to getSearchDsl`, async () => { - await findSuccess(commonOptions, namespace); + await findSuccess(client, repository, commonOptions, namespace); expect(mockGetSearchDsl).toHaveBeenCalledWith(mappings, registry, commonOptions); }); - it(`accepts typeToNamespacesMap`, async () => { - const relevantOpts = { - ...commonOptions, - type: '', - namespaces: [], - typeToNamespacesMap: new Map([[type, [namespace]]]), // can only be used when `type` is falsy and `namespaces` is an empty array - }; - - await findSuccess(relevantOpts, namespace); - expect(mockGetSearchDsl).toHaveBeenCalledWith(mappings, registry, { - ...relevantOpts, - type: [type], - }); - }); - - it('search for the right fields when typeToNamespacesMap is set', async () => { - const relevantOpts = { - ...commonOptions, - fields: ['title'], - type: '', - namespaces: [], - typeToNamespacesMap: new Map([[type, [namespace]]]), - }; - - await findSuccess(relevantOpts, namespace); - const esOptions = client.search.mock.calls[0][0]; - // @ts-expect-error _source not a top property for typesWithBodyKey - expect(esOptions?._source ?? []).toContain('index-pattern.title'); - }); - it(`accepts hasReferenceOperator`, async () => { const relevantOpts: SavedObjectsFindOptions = { ...commonOptions, hasReferenceOperator: 'AND', }; - await findSuccess(relevantOpts, namespace); + await findSuccess(client, repository, relevantOpts, namespace); expect(mockGetSearchDsl).toHaveBeenCalledWith(mappings, registry, { ...relevantOpts, hasReferenceOperator: 'AND', @@ -4245,7 +3747,7 @@ describe('SavedObjectsRepository', () => { searchAfter: ['1', 'a'], }; - await findSuccess(relevantOpts, namespace); + await findSuccess(client, repository, relevantOpts, namespace); expect(mockGetSearchDsl).toHaveBeenCalledWith(mappings, registry, { ...relevantOpts, searchAfter: ['1', 'a'], @@ -4258,7 +3760,7 @@ describe('SavedObjectsRepository', () => { pit: { id: 'abc123', keepAlive: '2m' }, }; - await findSuccess(relevantOpts, namespace); + await findSuccess(client, repository, relevantOpts, namespace); expect(mockGetSearchDsl).toHaveBeenCalledWith(mappings, registry, { ...relevantOpts, pit: { id: 'abc123', keepAlive: '2m' }, @@ -4281,7 +3783,7 @@ describe('SavedObjectsRepository', () => { filter: 'dashboard.attributes.otherField: *', }; - await findSuccess(findOpts, namespace); + await findSuccess(client, repository, findOpts, namespace); const { kueryNode } = mockGetSearchDsl.mock.calls[0][2]; expect(kueryNode).toMatchInlineSnapshot(` Object { @@ -4318,7 +3820,7 @@ describe('SavedObjectsRepository', () => { filter: nodeTypes.function.buildNode('is', `dashboard.attributes.otherField`, '*'), }; - await findSuccess(findOpts, namespace); + await findSuccess(client, repository, findOpts, namespace); const { kueryNode } = mockGetSearchDsl.mock.calls[0][2]; expect(kueryNode).toMatchInlineSnapshot(` Object { @@ -4341,7 +3843,7 @@ describe('SavedObjectsRepository', () => { it(`supports multiple types`, async () => { const types = ['config', 'index-pattern']; - await findSuccess({ type: types }); + await findSuccess(client, repository, { type: types }); expect(mockGetSearchDsl).toHaveBeenCalledWith( mappings, @@ -4354,7 +3856,7 @@ describe('SavedObjectsRepository', () => { it(`filters out invalid types`, async () => { const types = ['config', 'unknownType', 'index-pattern']; - await findSuccess({ type: types }); + await findSuccess(client, repository, { type: types }); expect(mockGetSearchDsl).toHaveBeenCalledWith( mappings, @@ -4367,7 +3869,7 @@ describe('SavedObjectsRepository', () => { it(`filters out hidden types`, async () => { const types = ['config', HIDDEN_TYPE, 'index-pattern']; - await findSuccess({ type: types }); + await findSuccess(client, repository, { type: types }); expect(mockGetSearchDsl).toHaveBeenCalledWith( mappings, @@ -4386,36 +3888,14 @@ describe('SavedObjectsRepository', () => { const namespace = 'foo-namespace'; const originId = 'some-origin-id'; - const getSuccess = async ( - type: string, - id: string, - options?: SavedObjectsBaseOptions, - includeOriginId?: boolean - ) => { - const response = getMockGetResponse( - { - type, - id, - // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the - // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. - ...(includeOriginId && { originId }), - }, - options?.namespace - ); - client.get.mockResponseOnce(response); - const result = await savedObjectsRepository.get(type, id, options); - expect(client.get).toHaveBeenCalledTimes(1); - return result; - }; - describe('client calls', () => { it(`should use the ES get action`, async () => { - await getSuccess(type, id); + await getSuccess(client, repository, registry, type, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { - await getSuccess(type, id, { namespace }); + await getSuccess(client, repository, registry, type, id, { namespace }); expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ id: `${namespace}:${type}:${id}`, @@ -4425,7 +3905,7 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { - await getSuccess(type, id); + await getSuccess(client, repository, registry, type, id); expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ id: `${type}:${id}`, @@ -4435,7 +3915,7 @@ describe('SavedObjectsRepository', () => { }); it(`normalizes options.namespace from 'default' to undefined`, async () => { - await getSuccess(type, id, { namespace: 'default' }); + await getSuccess(client, repository, registry, type, id, { namespace: 'default' }); expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ id: `${type}:${id}`, @@ -4445,7 +3925,7 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { - await getSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); + await getSuccess(client, repository, registry, NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, @@ -4454,7 +3934,9 @@ describe('SavedObjectsRepository', () => { ); client.get.mockClear(); - await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); + await getSuccess(client, repository, registry, MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace, + }); expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, @@ -4470,15 +3952,15 @@ describe('SavedObjectsRepository', () => { id: string, options?: SavedObjectsBaseOptions ) => { - await expect(savedObjectsRepository.get(type, id, options)).rejects.toThrowError( - createGenericNotFoundError(type, id) + await expect(repository.get(type, id, options)).rejects.toThrowError( + createGenericNotFoundErrorPayload(type, id) ); }; it(`throws when options.namespace is '*'`, async () => { await expect( - savedObjectsRepository.get(type, id, { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + repository.get(type, id, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); }); it(`throws when type is invalid`, async () => { @@ -4512,7 +3994,11 @@ describe('SavedObjectsRepository', () => { }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); + const response = getMockGetResponse( + registry, + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + namespace + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -4525,7 +4011,7 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it(`formats the ES response`, async () => { - const result = await getSuccess(type, id); + const result = await getSuccess(client, repository, registry, type, id); expect(result).toEqual({ id, type, @@ -4540,21 +4026,27 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); + const result = await getSuccess( + client, + repository, + registry, + MULTI_NAMESPACE_ISOLATED_TYPE, + id + ); expect(result).toMatchObject({ namespaces: expect.any(Array), }); }); it(`include namespaces if type is not multi-namespace`, async () => { - const result = await getSuccess(type, id); + const result = await getSuccess(client, repository, registry, type, id); expect(result).toMatchObject({ namespaces: ['default'], }); }); it(`includes originId property if present in cluster call response`, async () => { - const result = await getSuccess(type, id, {}, true); + const result = await getSuccess(client, repository, registry, type, id, {}, originId); expect(result).toMatchObject({ originId }); }); }); @@ -4572,9 +4064,7 @@ describe('SavedObjectsRepository', () => { }; mockInternalBulkResolve.mockResolvedValue({ resolved_objects: [expectedResult] }); - await expect(savedObjectsRepository.resolve('obj-type', 'obj-id')).resolves.toEqual( - expectedResult - ); + await expect(repository.resolve('obj-type', 'obj-id')).resolves.toEqual(expectedResult); expect(mockInternalBulkResolve).toHaveBeenCalledTimes(1); expect(mockInternalBulkResolve).toHaveBeenCalledWith( expect.objectContaining({ objects: [{ type: 'obj-type', id: 'obj-id' }] }) @@ -4586,14 +4076,14 @@ describe('SavedObjectsRepository', () => { const expectedResult: InternalBulkResolveError = { type: 'obj-type', id: 'obj-id', error }; mockInternalBulkResolve.mockResolvedValue({ resolved_objects: [expectedResult] }); - await expect(savedObjectsRepository.resolve('foo', '2')).rejects.toEqual(error); + await expect(repository.resolve('foo', '2')).rejects.toEqual(error); }); it('throws when internalBulkResolve throws', async () => { const error = new Error('Oh no!'); mockInternalBulkResolve.mockRejectedValue(error); - await expect(savedObjectsRepository.resolve('foo', '2')).rejects.toEqual(error); + await expect(repository.resolve('foo', '2')).rejects.toEqual(error); }); }); @@ -4615,7 +4105,7 @@ describe('SavedObjectsRepository', () => { const isMultiNamespace = registry.isMultiNamespace(type); if (isMultiNamespace) { const response = - mockGetResponseValue ?? getMockGetResponse({ type, id }, options?.namespace); + mockGetResponseValue ?? getMockGetResponse(registry, { type, id }, options?.namespace); client.get.mockResponseOnce(response); } @@ -4643,7 +4133,7 @@ describe('SavedObjectsRepository', () => { }; }); - const result = await savedObjectsRepository.incrementCounter(type, id, fields, options); + const result = await repository.incrementCounter(type, id, fields, options); expect(client.get).toHaveBeenCalledTimes(isMultiNamespace ? 1 : 0); return result; }; @@ -4780,24 +4270,24 @@ describe('SavedObjectsRepository', () => { id: string, field: Array ) => { - await expect(savedObjectsRepository.incrementCounter(type, id, field)).rejects.toThrowError( - createUnsupportedTypeError(type) + await expect(repository.incrementCounter(type, id, field)).rejects.toThrowError( + createUnsupportedTypeErrorPayload(type) ); }; it(`throws when options.namespace is '*'`, async () => { await expect( - savedObjectsRepository.incrementCounter(type, id, counterFields, { + repository.incrementCounter(type, id, counterFields, { namespace: ALL_NAMESPACES_STRING, }) - ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); }); it(`throws when type is not a string`, async () => { const test = async (type: unknown) => { await expect( // @ts-expect-error type is supposed to be a string - savedObjectsRepository.incrementCounter(type, id, counterFields) + repository.incrementCounter(type, id, counterFields) ).rejects.toThrowError(`"type" argument must be a string`); expect(client.update).not.toHaveBeenCalled(); }; @@ -4809,9 +4299,9 @@ describe('SavedObjectsRepository', () => { }); it(`throws when id is empty`, async () => { - await expect( - savedObjectsRepository.incrementCounter(type, '', counterFields) - ).rejects.toThrowError(createBadRequestError('id cannot be empty')); + await expect(repository.incrementCounter(type, '', counterFields)).rejects.toThrowError( + createBadRequestErrorPayload('id cannot be empty') + ); expect(client.update).not.toHaveBeenCalled(); }); @@ -4819,7 +4309,7 @@ describe('SavedObjectsRepository', () => { const test = async (field: unknown[]) => { await expect( // @ts-expect-error field is of wrong type - savedObjectsRepository.incrementCounter(type, id, field) + repository.incrementCounter(type, id, field) ).rejects.toThrowError( `"counterFields" argument must be of type Array` ); @@ -4846,6 +4336,7 @@ describe('SavedObjectsRepository', () => { it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { const response = getMockGetResponse( + registry, { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, 'bar-namespace' ); @@ -4853,15 +4344,10 @@ describe('SavedObjectsRepository', () => { elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.incrementCounter( - MULTI_NAMESPACE_ISOLATED_TYPE, - id, - counterFields, - { - namespace, - } - ) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); + repository.incrementCounter(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }) + ).rejects.toThrowError(createConflictErrorPayload(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalledTimes(1); expect(mockPreflightCheckForCreate).not.toHaveBeenCalled(); expect(client.update).not.toHaveBeenCalled(); @@ -4877,13 +4363,10 @@ describe('SavedObjectsRepository', () => { { type: 'foo', id: 'bar', error: { type: 'aliasConflict' } }, ]); await expect( - savedObjectsRepository.incrementCounter( - MULTI_NAMESPACE_ISOLATED_TYPE, - id, - counterFields, - { namespace } - ) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); + repository.incrementCounter(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }) + ).rejects.toThrowError(createConflictErrorPayload(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalledTimes(1); expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1); expect(client.update).not.toHaveBeenCalled(); @@ -4953,7 +4436,7 @@ describe('SavedObjectsRepository', () => { }; }); - const response = await savedObjectsRepository.incrementCounter( + const response = await repository.incrementCounter( 'config', '6.0.0-alpha1', ['buildNum', 'apiCallsCount'], @@ -5031,55 +4514,6 @@ describe('SavedObjectsRepository', () => { ]; const originId = 'some-origin-id'; - const mockUpdateResponse = ( - type: string, - id: string, - options?: SavedObjectsUpdateOptions, - includeOriginId?: boolean - ) => { - client.update.mockResponseOnce( - { - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'updated', - // don't need the rest of the source for test purposes, just the namespace and namespaces attributes - get: { - _source: { - namespaces: [options?.namespace ?? 'default'], - namespace: options?.namespace, - - // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the - // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. - ...(includeOriginId && { originId }), - }, - }, - } as estypes.UpdateResponse, - { statusCode: 200 } - ); - }; - - const updateSuccess = async ( - type: string, - id: string, - attributes: T, - options?: SavedObjectsUpdateOptions, - internalOptions: { - includeOriginId?: boolean; - mockGetResponseValue?: estypes.GetResponse; - } = {} - ) => { - const { mockGetResponseValue, includeOriginId } = internalOptions; - if (registry.isMultiNamespace(type)) { - const mockGetResponse = - mockGetResponseValue ?? getMockGetResponse({ type, id }, options?.namespace); - client.get.mockResponseOnce(mockGetResponse, { statusCode: 200 }); - } - mockUpdateResponse(type, id, options, includeOriginId); - const result = await savedObjectsRepository.update(type, id, attributes, options); - expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0); - return result; - }; - beforeEach(() => { mockPreflightCheckForCreate.mockReset(); mockPreflightCheckForCreate.mockImplementation(({ objects }) => { @@ -5089,14 +4523,21 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES update action when type is not multi-namespace`, async () => { - await updateSuccess(type, id, attributes); + await updateSuccess(client, repository, registry, type, id, attributes); expect(client.get).not.toHaveBeenCalled(); expect(mockPreflightCheckForCreate).not.toHaveBeenCalled(); expect(client.update).toHaveBeenCalledTimes(1); }); it(`should use the ES get action then update action when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); + await updateSuccess( + client, + repository, + registry, + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + attributes + ); expect(client.get).toHaveBeenCalledTimes(1); expect(mockPreflightCheckForCreate).not.toHaveBeenCalled(); expect(client.update).toHaveBeenCalledTimes(1); @@ -5104,6 +4545,9 @@ describe('SavedObjectsRepository', () => { it(`should check for alias conflicts if a new multi-namespace object would be created`, async () => { await updateSuccess( + client, + repository, + registry, MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, @@ -5116,7 +4560,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to no references array`, async () => { - await updateSuccess(type, id, attributes); + await updateSuccess(client, repository, registry, type, id, attributes); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, @@ -5127,7 +4571,7 @@ describe('SavedObjectsRepository', () => { it(`accepts custom references array`, async () => { const test = async (references: SavedObjectReference[]) => { - await updateSuccess(type, id, attributes, { references }); + await updateSuccess(client, repository, registry, type, id, attributes, { references }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ body: { doc: expect.objectContaining({ references }) }, @@ -5142,7 +4586,7 @@ describe('SavedObjectsRepository', () => { }); it(`uses the 'upsertAttributes' option when specified for a single-namespace type`, async () => { - await updateSuccess(type, id, attributes, { + await updateSuccess(client, repository, registry, type, id, attributes, { upsert: { title: 'foo', description: 'bar', @@ -5167,8 +4611,8 @@ describe('SavedObjectsRepository', () => { it(`uses the 'upsertAttributes' option when specified for a multi-namespace type that does not exist`, async () => { const options = { upsert: { title: 'foo', description: 'bar' } }; - mockUpdateResponse(MULTI_NAMESPACE_ISOLATED_TYPE, id, options); - await savedObjectsRepository.update(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, options); + mockUpdateResponse(client, MULTI_NAMESPACE_ISOLATED_TYPE, id, options); + await repository.update(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, options); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -5189,7 +4633,15 @@ describe('SavedObjectsRepository', () => { it(`ignores use the 'upsertAttributes' option when specified for a multi-namespace type that already exists`, async () => { const options = { upsert: { title: 'foo', description: 'bar' } }; - await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, options); + await updateSuccess( + client, + repository, + registry, + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + attributes, + options + ); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:logstash-*`, @@ -5204,7 +4656,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't accept custom references if not an array`, async () => { const test = async (references: unknown) => { // @ts-expect-error references is unknown - await updateSuccess(type, id, attributes, { references }); + await updateSuccess(client, repository, registry, type, id, attributes, { references }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, @@ -5220,7 +4672,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to a refresh setting of wait_for`, async () => { - await updateSuccess(type, id, { foo: 'bar' }); + await updateSuccess(client, repository, registry, type, id, { foo: 'bar' }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ refresh: 'wait_for', @@ -5230,7 +4682,15 @@ describe('SavedObjectsRepository', () => { }); it(`does not default to the version of the existing document when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { references }); + await updateSuccess( + client, + repository, + registry, + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + attributes, + { references } + ); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -5242,7 +4702,7 @@ describe('SavedObjectsRepository', () => { }); it(`accepts version`, async () => { - await updateSuccess(type, id, attributes, { + await updateSuccess(client, repository, registry, type, id, attributes, { version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), }); expect(client.update).toHaveBeenCalledWith( @@ -5252,7 +4712,7 @@ describe('SavedObjectsRepository', () => { }); it('default to a `retry_on_conflict` setting of `3` when `version` is not provided', async () => { - await updateSuccess(type, id, attributes, {}); + await updateSuccess(client, repository, registry, type, id, attributes, {}); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ retry_on_conflict: 3 }), expect.anything() @@ -5260,7 +4720,7 @@ describe('SavedObjectsRepository', () => { }); it('default to a `retry_on_conflict` setting of `0` when `version` is provided', async () => { - await updateSuccess(type, id, attributes, { + await updateSuccess(client, repository, registry, type, id, attributes, { version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), }); expect(client.update).toHaveBeenCalledWith( @@ -5270,7 +4730,7 @@ describe('SavedObjectsRepository', () => { }); it('accepts a `retryOnConflict` option', async () => { - await updateSuccess(type, id, attributes, { + await updateSuccess(client, repository, registry, type, id, attributes, { version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), retryOnConflict: 42, }); @@ -5281,7 +4741,7 @@ describe('SavedObjectsRepository', () => { }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { - await updateSuccess(type, id, attributes, { namespace }); + await updateSuccess(client, repository, registry, type, id, attributes, { namespace }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ id: expect.stringMatching(`${namespace}:${type}:${id}`) }), expect.anything() @@ -5289,7 +4749,7 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { - await updateSuccess(type, id, attributes, { references }); + await updateSuccess(client, repository, registry, type, id, attributes, { references }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ id: expect.stringMatching(`${type}:${id}`) }), expect.anything() @@ -5297,7 +4757,10 @@ describe('SavedObjectsRepository', () => { }); it(`normalizes options.namespace from 'default' to undefined`, async () => { - await updateSuccess(type, id, attributes, { references, namespace: 'default' }); + await updateSuccess(client, repository, registry, type, id, attributes, { + references, + namespace: 'default', + }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ id: expect.stringMatching(`${type}:${id}`) }), expect.anything() @@ -5305,7 +4768,9 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { - await updateSuccess(NAMESPACE_AGNOSTIC_TYPE, id, attributes, { namespace }); + await updateSuccess(client, repository, registry, NAMESPACE_AGNOSTIC_TYPE, id, attributes, { + namespace, + }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ id: expect.stringMatching(`${NAMESPACE_AGNOSTIC_TYPE}:${id}`), @@ -5314,7 +4779,15 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { namespace }); + await updateSuccess( + client, + repository, + registry, + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + attributes, + { namespace } + ); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ id: expect.stringMatching(`${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`), @@ -5324,7 +4797,14 @@ describe('SavedObjectsRepository', () => { }); it(`includes _source_includes when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); + await updateSuccess( + client, + repository, + registry, + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + attributes + ); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'] }), expect.anything() @@ -5332,7 +4812,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes _source_includes when type is not multi-namespace`, async () => { - await updateSuccess(type, id, attributes); + await updateSuccess(client, repository, registry, type, id, attributes); expect(client.update).toHaveBeenLastCalledWith( expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'], @@ -5344,15 +4824,15 @@ describe('SavedObjectsRepository', () => { describe('errors', () => { const expectNotFoundError = async (type: string, id: string) => { - await expect(savedObjectsRepository.update(type, id, {})).rejects.toThrowError( - createGenericNotFoundError(type, id) + await expect(repository.update(type, id, {})).rejects.toThrowError( + createGenericNotFoundErrorPayload(type, id) ); }; it(`throws when options.namespace is '*'`, async () => { await expect( - savedObjectsRepository.update(type, id, attributes, { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + repository.update(type, id, attributes, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); }); it(`throws when type is invalid`, async () => { @@ -5366,8 +4846,8 @@ describe('SavedObjectsRepository', () => { }); it(`throws when id is empty`, async () => { - await expect(savedObjectsRepository.update(type, '', attributes)).rejects.toThrowError( - createBadRequestError('id cannot be empty') + await expect(repository.update(type, '', attributes)).rejects.toThrowError( + createBadRequestErrorPayload('id cannot be empty') ); expect(client.update).not.toHaveBeenCalled(); }); @@ -5394,7 +4874,11 @@ describe('SavedObjectsRepository', () => { }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); + const response = getMockGetResponse( + registry, + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + namespace + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -5412,7 +4896,7 @@ describe('SavedObjectsRepository', () => { { type: 'type', id: 'id', error: { type: 'aliasConflict' } }, ]); await expect( - savedObjectsRepository.update( + repository.update( MULTI_NAMESPACE_ISOLATED_TYPE, id, { attr: 'value' }, @@ -5423,7 +4907,7 @@ describe('SavedObjectsRepository', () => { }, } ) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); + ).rejects.toThrowError(createConflictErrorPayload(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalledTimes(1); expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1); expect(client.update).not.toHaveBeenCalled(); @@ -5434,6 +4918,9 @@ describe('SavedObjectsRepository', () => { { type: 'type', id: 'id', error: { type: 'conflict' } }, ]); await updateSuccess( + client, + repository, + registry, MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, @@ -5462,7 +4949,7 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it(`returns _seq_no and _primary_term encoded as version`, async () => { - const result = await updateSuccess(type, id, attributes, { + const result = await updateSuccess(client, repository, registry, type, id, attributes, { namespace, references, }); @@ -5478,21 +4965,37 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); + const result = await updateSuccess( + client, + repository, + registry, + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + attributes + ); expect(result).toMatchObject({ namespaces: expect.any(Array), }); }); it(`includes namespaces if type is not multi-namespace`, async () => { - const result = await updateSuccess(type, id, attributes); + const result = await updateSuccess(client, repository, registry, type, id, attributes); expect(result).toMatchObject({ namespaces: ['default'], }); }); it(`includes originId property if present in cluster call response`, async () => { - const result = await updateSuccess(type, id, attributes, {}, { includeOriginId: true }); + const result = await updateSuccess( + client, + repository, + registry, + type, + id, + attributes, + {}, + { originId } + ); expect(result).toMatchObject({ originId }); }); }); @@ -5504,7 +5007,7 @@ describe('SavedObjectsRepository', () => { const generateResults = (id?: string) => ({ id: id || 'id' }); const successResponse = async (type: string, options?: SavedObjectsOpenPointInTimeOptions) => { client.openPointInTime.mockResponseOnce(generateResults()); - const result = await savedObjectsRepository.openPointInTimeForType(type, options); + const result = await repository.openPointInTimeForType(type, options); expect(client.openPointInTime).toHaveBeenCalledTimes(1); return result; }; @@ -5548,8 +5051,8 @@ describe('SavedObjectsRepository', () => { describe('errors', () => { const expectNotFoundError = async (types: string | string[]) => { - await expect(savedObjectsRepository.openPointInTimeForType(types)).rejects.toThrowError( - createGenericNotFoundError() + await expect(repository.openPointInTimeForType(types)).rejects.toThrowError( + createGenericNotFoundErrorPayload() ); }; @@ -5583,7 +5086,7 @@ describe('SavedObjectsRepository', () => { client.openPointInTime.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(results) ); - const response = await savedObjectsRepository.openPointInTimeForType(type); + const response = await repository.openPointInTimeForType(type); expect(response).toEqual({ id }); }); }); @@ -5593,7 +5096,7 @@ describe('SavedObjectsRepository', () => { const generateResults = () => ({ succeeded: true, num_freed: 3 }); const successResponse = async (id: string) => { client.closePointInTime.mockResponseOnce(generateResults()); - const result = await savedObjectsRepository.closePointInTime(id); + const result = await repository.closePointInTime(id); expect(client.closePointInTime).toHaveBeenCalledTimes(1); return result; }; @@ -5621,7 +5124,7 @@ describe('SavedObjectsRepository', () => { it(`returns response body from ES`, async () => { const results = generateResults(); client.closePointInTime.mockResponseOnce(results); - const response = await savedObjectsRepository.closePointInTime('abc123'); + const response = await repository.closePointInTime('abc123'); expect(response).toEqual(results); }); }); @@ -5629,7 +5132,7 @@ describe('SavedObjectsRepository', () => { describe('#createPointInTimeFinder', () => { it('returns a new PointInTimeFinder instance', async () => { - const result = await savedObjectsRepository.createPointInTimeFinder({ type: 'PIT' }); + const result = await repository.createPointInTimeFinder({ type: 'PIT' }); expect(result).toBeInstanceOf(PointInTimeFinder); }); @@ -5645,7 +5148,7 @@ describe('SavedObjectsRepository', () => { }, }; - await savedObjectsRepository.createPointInTimeFinder(options, dependencies); + await repository.createPointInTimeFinder(options, dependencies); expect(pointInTimeFinderMock).toHaveBeenCalledWith( options, expect.objectContaining({ @@ -5670,9 +5173,9 @@ describe('SavedObjectsRepository', () => { }; mockCollectMultiNamespaceReferences.mockResolvedValue(expectedResult); - await expect( - savedObjectsRepository.collectMultiNamespaceReferences(objects) - ).resolves.toEqual(expectedResult); + await expect(repository.collectMultiNamespaceReferences(objects)).resolves.toEqual( + expectedResult + ); expect(mockCollectMultiNamespaceReferences).toHaveBeenCalledTimes(1); expect(mockCollectMultiNamespaceReferences).toHaveBeenCalledWith( expect.objectContaining({ objects }) @@ -5683,9 +5186,7 @@ describe('SavedObjectsRepository', () => { const expectedResult = new Error('Oh no!'); mockCollectMultiNamespaceReferences.mockRejectedValue(expectedResult); - await expect(savedObjectsRepository.collectMultiNamespaceReferences([])).rejects.toEqual( - expectedResult - ); + await expect(repository.collectMultiNamespaceReferences([])).rejects.toEqual(expectedResult); }); }); @@ -5711,7 +5212,7 @@ describe('SavedObjectsRepository', () => { mockUpdateObjectsSpaces.mockResolvedValue(expectedResult); await expect( - savedObjectsRepository.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options) + repository.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options) ).resolves.toEqual(expectedResult); expect(mockUpdateObjectsSpaces).toHaveBeenCalledTimes(1); expect(mockUpdateObjectsSpaces).toHaveBeenCalledWith( @@ -5723,9 +5224,7 @@ describe('SavedObjectsRepository', () => { const expectedResult = new Error('Oh no!'); mockUpdateObjectsSpaces.mockRejectedValue(expectedResult); - await expect(savedObjectsRepository.updateObjectsSpaces([], [], [])).rejects.toEqual( - expectedResult - ); + await expect(repository.updateObjectsSpaces([], [], [])).rejects.toEqual(expectedResult); }); }); }); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.ts index 5bf3403742623..65794ff6492fa 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.ts @@ -7,6 +7,7 @@ */ import { omit, isObject } from 'lodash'; +import Boom from '@hapi/boom'; import type { Payload } from '@hapi/boom'; import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import * as esKuery from '@kbn/es-query'; @@ -18,7 +19,6 @@ import { } from '@kbn/core-elasticsearch-server-internal'; import type { SavedObject } from '@kbn/core-saved-objects-common'; import type { - ISavedObjectsRepository, SavedObjectsBaseOptions, SavedObjectsIncrementCounterOptions, SavedObjectsDeleteByNamespaceOptions, @@ -57,14 +57,24 @@ import type { SavedObjectsBulkDeleteObject, SavedObjectsBulkDeleteOptions, SavedObjectsBulkDeleteResponse, + SavedObjectsFindInternalOptions, + ISavedObjectsRepository, } from '@kbn/core-saved-objects-api-server'; -import type { - SavedObjectSanitizedDoc, - SavedObjectsRawDoc, - SavedObjectsRawDocSource, - ISavedObjectTypeRegistry, +import { + type SavedObjectSanitizedDoc, + type SavedObjectsRawDoc, + type SavedObjectsRawDocSource, + type ISavedObjectTypeRegistry, + type SavedObjectsExtensions, + type ISavedObjectsEncryptionExtension, + type ISavedObjectsSecurityExtension, + type ISavedObjectsSpacesExtension, + AuditAction, + type CheckAuthorizationResult, + type AuthorizationTypeMap, } from '@kbn/core-saved-objects-server'; import { + DEFAULT_NAMESPACE_STRING, SavedObjectsErrorHelpers, type DecoratedError, } from '@kbn/core-saved-objects-utils-server'; @@ -88,10 +98,14 @@ import { } from '@kbn/core-saved-objects-base-server-internal'; import pMap from 'p-map'; import { PointInTimeFinder } from './point_in_time_finder'; -import { createRepositoryEsClient, RepositoryEsClient } from './repository_es_client'; +import { createRepositoryEsClient, type RepositoryEsClient } from './repository_es_client'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; -import { internalBulkResolve, InternalBulkResolveError } from './internal_bulk_resolve'; +import { + internalBulkResolve, + type InternalBulkResolveError, + isBulkResolveError, +} from './internal_bulk_resolve'; import { validateConvertFilterToKueryNode } from './filter_utils'; import { validateAndConvertAggregations } from './aggregations'; import { @@ -102,7 +116,7 @@ import { normalizeNamespace, rawDocExistsInNamespace, rawDocExistsInNamespaces, - Either, + type Either, isLeft, isRight, } from './internal_utils'; @@ -110,7 +124,8 @@ import { collectMultiNamespaceReferences } from './collect_multi_namespace_refer import { updateObjectsSpaces } from './update_objects_spaces'; import { preflightCheckForCreate, - PreflightCheckForCreateObject, + type PreflightCheckForCreateObject, + type PreflightCheckForCreateResult, } from './preflight_check_for_create'; import { deleteLegacyUrlAliases } from './legacy_url_aliases'; import type { @@ -136,12 +151,14 @@ export interface SavedObjectsRepositoryOptions { migrator: IKibanaMigrator; allowedTypes: string[]; logger: Logger; + extensions?: SavedObjectsExtensions; } export const DEFAULT_REFRESH_SETTING = 'wait_for'; export const DEFAULT_RETRY_COUNT = 3; const MAX_CONCURRENT_ALIAS_DELETIONS = 10; + /** * @internal */ @@ -176,6 +193,11 @@ function isMgetDoc(doc?: estypes.MgetResponseItem): doc is estypes.GetG } /** + * Saved Objects Respositiry - the client entry point for saved object manipulation. + * + * The SOR calls the Elasticsearch client and leverages extension implementations to + * support spaces, security, and encryption features. + * * @public */ export class SavedObjectsRepository implements ISavedObjectsRepository { @@ -185,6 +207,9 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { private _registry: ISavedObjectTypeRegistry; private _allowedTypes: string[]; private readonly client: RepositoryEsClient; + private readonly _encryptionExtension?: ISavedObjectsEncryptionExtension; + private readonly _securityExtension?: ISavedObjectsSecurityExtension; + private readonly _spacesExtension?: ISavedObjectsSpacesExtension; private _serializer: SavedObjectsSerializer; private _logger: Logger; @@ -203,6 +228,8 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { client: ElasticsearchClient, logger: Logger, includedHiddenTypes: string[] = [], + extensions?: SavedObjectsExtensions, + /** The injectedConstructor is only used for unit testing */ injectedConstructor: any = SavedObjectsRepository ): ISavedObjectsRepository { const mappings = migrator.getActiveMappings(); @@ -228,6 +255,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { allowedTypes, client, logger, + extensions, }); } @@ -241,6 +269,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { migrator, allowedTypes = [], logger, + extensions, } = options; // It's important that we migrate documents / mark them as up-to-date @@ -261,6 +290,9 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { this._allowedTypes = allowedTypes; this._serializer = serializer; this._logger = logger; + this._encryptionExtension = extensions?.encryptionExtension; + this._securityExtension = extensions?.securityExtension; + this._spacesExtension = extensions?.spacesExtension; } /** @@ -271,6 +303,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { attributes: T, options: SavedObjectsCreateOptions = {} ): Promise> { + const namespace = this.getCurrentNamespace(options.namespace); const { migrationVersion, coreMigrationVersion, @@ -280,21 +313,20 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { initialNamespaces, version, } = options; - const id = options.id || SavedObjectsUtils.generateId(); - const namespace = normalizeNamespace(options.namespace); - - this.validateInitialNamespaces(type, initialNamespaces); - this.validateOriginId(type, options); - if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } + const id = this.getValidId(type, options.id, options.version, options.overwrite); + this.validateInitialNamespaces(type, initialNamespaces); + this.validateOriginId(type, options); const time = getCurrentTime(); let savedObjectNamespace: string | undefined; let savedObjectNamespaces: string[] | undefined; let existingOriginId: string | undefined; + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + let preflightResult: PreflightCheckForCreateResult | undefined; if (this._registry.isSingleNamespace(type)) { savedObjectNamespace = initialNamespaces ? normalizeNamespace(initialNamespaces[0]) @@ -303,24 +335,55 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { if (options.id) { // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces // note: this check throws an error if the object is found but does not exist in this namespace - const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); - const [{ error, existingDocument }] = await preflightCheckForCreate({ - registry: this._registry, - client: this.client, - serializer: this._serializer, - getIndexForType: this.getIndexForType.bind(this), - createPointInTimeFinder: this.createPointInTimeFinder.bind(this), - objects: [{ type, id, overwrite, namespaces: initialNamespaces ?? [namespaceString] }], - }); - if (error) { - throw SavedObjectsErrorHelpers.createConflictError(type, id); - } - savedObjectNamespaces = - initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); - existingOriginId = existingDocument?._source?.originId; - } else { - savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); + preflightResult = ( + await preflightCheckForCreate({ + registry: this._registry, + client: this.client, + serializer: this._serializer, + getIndexForType: this.getIndexForType.bind(this), + createPointInTimeFinder: this.createPointInTimeFinder.bind(this), + objects: [{ type, id, overwrite, namespaces: initialNamespaces ?? [namespaceString] }], + }) + )[0]; } + savedObjectNamespaces = + initialNamespaces || getSavedObjectNamespaces(namespace, preflightResult?.existingDocument); + existingOriginId = preflightResult?.existingDocument?._source?.originId; + } + + const spacesToEnforce = new Set(initialNamespaces).add(namespaceString); // Always check/enforce authZ for the active space + const existingNamespaces = preflightResult?.existingDocument?._source?.namespaces || []; + const spacesToAuthorize = new Set(existingNamespaces); + spacesToAuthorize.delete(ALL_NAMESPACES_STRING); // Don't accidentally check for global privileges when the object exists in '*' + const authorizationResult = await this._securityExtension?.checkAuthorization({ + types: new Set([type]), + spaces: new Set([...spacesToEnforce, ...spacesToAuthorize]), // existing namespaces are included so we can later redact if necessary + actions: new Set(['create']), + // If a user tries to create an object with `initialNamespaces: ['*']`, they need to have 'create' privileges for the Global Resource + // (e.g., All privileges for All Spaces). + // Inversely, if a user tries to overwrite an object that already exists in '*', they don't need to 'create' privileges for the Global + // Resource, so in that case we have to filter out that string from spacesToAuthorize (because `allowGlobalResource: true` is used + // below.) + options: { allowGlobalResource: true }, + }); + if (authorizationResult) { + this._securityExtension!.enforceAuthorization({ + typesAndSpaces: new Map([[type, spacesToEnforce]]), + action: 'create', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => + this._securityExtension!.addAuditEvent({ + action: AuditAction.CREATE, + savedObject: { type, id }, + error, + ...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the create operation has not occurred yet + }), + }); + } + + if (preflightResult?.error) { + // This intentionally occurs _after_ the authZ enforcement (which may throw a 403 error earlier) + throw SavedObjectsErrorHelpers.createConflictError(type, id); } // 1. If the originId has been *explicitly set* in the options (defined or undefined), respect that. @@ -334,7 +397,12 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), originId, - attributes, + attributes: await this.optionallyEncryptAttributes( + type, + id, + savedObjectNamespace, // if single namespace type, this is the first in initialNamespaces. If multi-namespace type this is options.namespace/current namespace. + attributes + ), migrationVersion, coreMigrationVersion, created_at: time, @@ -371,10 +439,11 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(id, type); } - return this._rawToSavedObject({ - ...raw, - ...body, - }); + return this.optionallyDecryptAndRedactSingleResult( + this._rawToSavedObject({ ...raw, ...body }), + authorizationResult?.typeMap, + attributes + ); } /** @@ -384,27 +453,28 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: Array>, options: SavedObjectsCreateOptions = {} ): Promise> { + const namespace = this.getCurrentNamespace(options.namespace); const { overwrite = false, refresh = DEFAULT_REFRESH_SETTING } = options; - const namespace = normalizeNamespace(options.namespace); const time = getCurrentTime(); let preflightCheckIndexCounter = 0; - const expectedResults = objects.map< - Either< - { type: string; id?: string; error: Payload }, - { - method: 'index' | 'create'; - object: SavedObjectsBulkCreateObject & { id: string }; - preflightCheckIndex?: number; - } - > - >((object) => { - const { type, id, initialNamespaces } = object; + type ExpectedResult = Either< + { type: string; id?: string; error: Payload }, + { + method: 'index' | 'create'; + object: SavedObjectsBulkCreateObject & { id: string }; + preflightCheckIndex?: number; + } + >; + const expectedResults = objects.map((object) => { + const { type, id: requestId, initialNamespaces, version } = object; let error: DecoratedError | undefined; + let id: string = ''; // Assign to make TS happy, the ID will be validated (or randomly generated if needed) during getValidId below if (!this._allowedTypes.includes(type)) { error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } else { try { + id = this.getValidId(type, requestId, version, overwrite); this.validateInitialNamespaces(type, initialNamespaces); this.validateOriginId(type, object); } catch (e) { @@ -415,26 +485,36 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { if (error) { return { tag: 'Left', - value: { id, type, error: errorContent(error) }, + value: { id: requestId, type, error: errorContent(error) }, }; } - const method = id && overwrite ? 'index' : 'create'; - const requiresNamespacesCheck = id && this._registry.isMultiNamespace(type); + const method = requestId && overwrite ? 'index' : 'create'; + const requiresNamespacesCheck = requestId && this._registry.isMultiNamespace(type); return { tag: 'Right', value: { method, - object: { ...object, id: object.id || SavedObjectsUtils.generateId() }, + object: { ...object, id }, ...(requiresNamespacesCheck && { preflightCheckIndex: preflightCheckIndexCounter++ }), }, }; }); + const validObjects = expectedResults.filter(isRight); + if (validObjects.length === 0) { + // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. + return { + // Technically the returned array should only contain SavedObject results, but for errors this is not true (we cast to 'unknown' below) + saved_objects: expectedResults.map>( + ({ value }) => value as unknown as SavedObject + ), + }; + } + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); - const preflightCheckObjects = expectedResults - .filter(isRight) + const preflightCheckObjects = validObjects .filter(({ value }) => value.preflightCheckIndex !== undefined) .map(({ value }) => { const { type, id, initialNamespaces } = value.object; @@ -450,116 +530,169 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: preflightCheckObjects, }); + const typesAndSpaces = new Map>(); + const spacesToAuthorize = new Set([namespaceString]); // Always check authZ for the active space + for (const { value } of validObjects) { + const { object, preflightCheckIndex: index } = value; + const preflightResult = index !== undefined ? preflightCheckResponse[index] : undefined; + + const spacesToEnforce = typesAndSpaces.get(object.type) ?? new Set([namespaceString]); // Always enforce authZ for the active space + for (const space of object.initialNamespaces ?? []) { + spacesToEnforce.add(space); + spacesToAuthorize.add(space); + } + typesAndSpaces.set(object.type, spacesToEnforce); + for (const space of preflightResult?.existingDocument?._source.namespaces ?? []) { + if (space === ALL_NAMESPACES_STRING) continue; // Don't accidentally check for global privileges when the object exists in '*' + spacesToAuthorize.add(space); // existing namespaces are included so we can later redact if necessary + } + } + + const authorizationResult = await this._securityExtension?.checkAuthorization({ + types: new Set(typesAndSpaces.keys()), + spaces: spacesToAuthorize, + actions: new Set(['bulk_create']), + // If a user tries to create an object with `initialNamespaces: ['*']`, they need to have 'bulk_create' privileges for the Global + // Resource (e.g., All privileges for All Spaces). + // Inversely, if a user tries to overwrite an object that already exists in '*', they don't need to have 'bulk_create' privileges for the Global + // Resource, so in that case we have to filter out that string from spacesToAuthorize (because `allowGlobalResource: true` is used + // below.) + options: { allowGlobalResource: true }, + }); + if (authorizationResult) { + this._securityExtension!.enforceAuthorization({ + typesAndSpaces, + action: 'bulk_create', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => { + for (const { value } of validObjects) { + this._securityExtension!.addAuditEvent({ + action: AuditAction.CREATE, + savedObject: { type: value.object.type, id: value.object.id }, + error, + ...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the create operation has not occurred yet + }); + } + }, + }); + } + let bulkRequestIndexCounter = 0; const bulkCreateParams: object[] = []; - const expectedBulkResults = expectedResults.map< - Either< - { type: string; id?: string; error: Payload }, - { esRequestIndex: number; requestedId: string; rawMigratedDoc: SavedObjectsRawDoc } - > - >((expectedBulkGetResult) => { - if (isLeft(expectedBulkGetResult)) { - return expectedBulkGetResult; - } + type ExpectedBulkResult = Either< + { type: string; id?: string; error: Payload }, + { esRequestIndex: number; requestedId: string; rawMigratedDoc: SavedObjectsRawDoc } + >; + const expectedBulkResults = await Promise.all( + expectedResults.map>(async (expectedBulkGetResult) => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } - let savedObjectNamespace: string | undefined; - let savedObjectNamespaces: string[] | undefined; - let existingOriginId: string | undefined; - let versionProperties; - const { - preflightCheckIndex, - object: { initialNamespaces, version, ...object }, - method, - } = expectedBulkGetResult.value; - if (preflightCheckIndex !== undefined) { - const preflightResult = preflightCheckResponse[preflightCheckIndex]; - const { type, id, existingDocument, error } = preflightResult; - if (error) { - const { metadata } = error; + let savedObjectNamespace: string | undefined; + let savedObjectNamespaces: string[] | undefined; + let existingOriginId: string | undefined; + let versionProperties; + const { + preflightCheckIndex, + object: { initialNamespaces, version, ...object }, + method, + } = expectedBulkGetResult.value; + if (preflightCheckIndex !== undefined) { + const preflightResult = preflightCheckResponse[preflightCheckIndex]; + const { type, id, existingDocument, error } = preflightResult; + if (error) { + const { metadata } = error; + return { + tag: 'Left', + value: { + id, + type, + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + ...(metadata && { metadata }), + }, + }, + }; + } + savedObjectNamespaces = + initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); + versionProperties = getExpectedVersionProperties(version); + existingOriginId = existingDocument?._source?.originId; + } else { + if (this._registry.isSingleNamespace(object.type)) { + savedObjectNamespace = initialNamespaces + ? normalizeNamespace(initialNamespaces[0]) + : namespace; + } else if (this._registry.isMultiNamespace(object.type)) { + savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); + } + versionProperties = getExpectedVersionProperties(version); + } + + // 1. If the originId has been *explicitly set* in the options (defined or undefined), respect that. + // 2. Otherwise, preserve the originId of the existing object that is being overwritten, if any. + const originId = Object.keys(object).includes('originId') + ? object.originId + : existingOriginId; + const migrated = this._migrator.migrateDocument({ + id: object.id, + type: object.type, + attributes: await this.optionallyEncryptAttributes( + object.type, + object.id, + savedObjectNamespace, // only used for multi-namespace object types + object.attributes + ), + migrationVersion: object.migrationVersion, + coreMigrationVersion: object.coreMigrationVersion, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + updated_at: time, + created_at: time, + references: object.references || [], + originId, + }) as SavedObjectSanitizedDoc; + + /** + * If a validation has been registered for this type, we run it against the migrated attributes. + * This is an imperfect solution because malformed attributes could have already caused the + * migration to fail, but it's the best we can do without devising a way to run validations + * inside the migration algorithm itself. + */ + try { + this.validateObjectAttributes(object.type, migrated); + } catch (error) { return { tag: 'Left', value: { - id, - type, - error: { - ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), - ...(metadata && { metadata }), - }, + id: object.id, + type: object.type, + error, }, }; } - savedObjectNamespaces = - initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); - versionProperties = getExpectedVersionProperties(version); - existingOriginId = existingDocument?._source?.originId; - } else { - if (this._registry.isSingleNamespace(object.type)) { - savedObjectNamespace = initialNamespaces - ? normalizeNamespace(initialNamespaces[0]) - : namespace; - } else if (this._registry.isMultiNamespace(object.type)) { - savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); - } - versionProperties = getExpectedVersionProperties(version); - } - // 1. If the originId has been *explicitly set* for the object (defined or undefined), respect that. - // 2. Otherwise, preserve the originId of the existing object that is being overwritten, if any. - const originId = Object.keys(object).includes('originId') - ? object.originId - : existingOriginId; - const migrated = this._migrator.migrateDocument({ - id: object.id, - type: object.type, - attributes: object.attributes, - migrationVersion: object.migrationVersion, - coreMigrationVersion: object.coreMigrationVersion, - ...(savedObjectNamespace && { namespace: savedObjectNamespace }), - ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), - updated_at: time, - created_at: time, - references: object.references || [], - originId, - }) as SavedObjectSanitizedDoc; - - /** - * If a validation has been registered for this type, we run it against the migrated attributes. - * This is an imperfect solution because malformed attributes could have already caused the - * migration to fail, but it's the best we can do without devising a way to run validations - * inside the migration algorithm itself. - */ - try { - this.validateObjectAttributes(object.type, migrated); - } catch (error) { - return { - tag: 'Left', - value: { - id: object.id, - type: object.type, - error, - }, + const expectedResult = { + esRequestIndex: bulkRequestIndexCounter++, + requestedId: object.id, + rawMigratedDoc: this._serializer.savedObjectToRaw(migrated), }; - } - - const expectedResult = { - esRequestIndex: bulkRequestIndexCounter++, - requestedId: object.id, - rawMigratedDoc: this._serializer.savedObjectToRaw(migrated), - }; - bulkCreateParams.push( - { - [method]: { - _id: expectedResult.rawMigratedDoc._id, - _index: this.getIndexForType(object.type), - ...(overwrite && versionProperties), + bulkCreateParams.push( + { + [method]: { + _id: expectedResult.rawMigratedDoc._id, + _index: this.getIndexForType(object.type), + ...(overwrite && versionProperties), + }, }, - }, - expectedResult.rawMigratedDoc._source - ); + expectedResult.rawMigratedDoc._source + ); - return { tag: 'Right', value: expectedResult }; - }); + return { tag: 'Right', value: expectedResult }; + }) + ); const bulkResponse = bulkCreateParams.length ? await this.client.bulk({ @@ -569,7 +702,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { }) : undefined; - return { + const result = { saved_objects: expectedBulkResults.map((expectedResult) => { if (isLeft(expectedResult)) { return expectedResult.value as any; @@ -592,6 +725,8 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { }); }), }; + + return this.optionallyDecryptAndRedactBulkResult(result, authorizationResult?.typeMap, objects); } /** @@ -601,39 +736,64 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: SavedObjectsCheckConflictsObject[] = [], options: SavedObjectsBaseOptions = {} ): Promise { + const namespace = this.getCurrentNamespace(options.namespace); + if (objects.length === 0) { return { errors: [] }; } - const namespace = normalizeNamespace(options.namespace); - let bulkGetRequestIndexCounter = 0; - const expectedBulkGetResults: Array, Record>> = - objects.map((object) => { - const { type, id } = object; - - if (!this._allowedTypes.includes(type)) { - return { - tag: 'Left', - value: { - id, - type, - error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), - }, - }; - } + type ExpectedBulkGetResult = Either< + { type: string; id: string; error: Payload }, + { type: string; id: string; esRequestIndex: number } + >; + const expectedBulkGetResults = objects.map((object) => { + const { type, id } = object; + if (!this._allowedTypes.includes(type)) { return { - tag: 'Right', + tag: 'Left', value: { - type, id, - esRequestIndex: bulkGetRequestIndexCounter++, + type, + error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), }, }; + } + + return { + tag: 'Right', + value: { + type, + id, + esRequestIndex: bulkGetRequestIndexCounter++, + }, + }; + }); + + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + const validObjects = expectedBulkGetResults.filter(isRight); + const typesAndSpaces = new Map>(); + for (const { value } of validObjects) { + typesAndSpaces.set(value.type, new Set([namespaceString])); // Always enforce authZ for the active space + } + const authorizationResult = await this._securityExtension?.checkAuthorization({ + types: new Set(typesAndSpaces.keys()), + spaces: new Set([namespaceString]), // Always check authZ for the active space + actions: new Set(['bulk_create']), + }); + if (authorizationResult) { + this._securityExtension!.enforceAuthorization({ + typesAndSpaces, + action: 'bulk_create', + typeMap: authorizationResult.typeMap, + // auditCallback is intentionally omitted, this function in the previous Security SOC wrapper implementation + // did not have audit logging. This is primarily because it is only used by Kibana and is not exposed in a + // public HTTP API }); + } - const bulkGetDocs = expectedBulkGetResults.filter(isRight).map(({ value: { type, id } }) => ({ + const bulkGetDocs = validObjects.map(({ value: { type, id } }) => ({ _id: this._serializer.generateRawId(namespace, type, id), _index: this.getIndexForType(type), _source: { includes: ['type', 'namespaces'] }, @@ -690,11 +850,36 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { * {@inheritDoc ISavedObjectsRepository.delete} */ async delete(type: string, id: string, options: SavedObjectsDeleteOptions = {}): Promise<{}> { + const namespace = this.getCurrentNamespace(options.namespace); + if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } + const { refresh = DEFAULT_REFRESH_SETTING, force } = options; - const namespace = normalizeNamespace(options.namespace); + + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + const typesAndSpaces = new Map>([[type, new Set([namespaceString])]]); // Always enforce authZ for the active space + const authorizationResult = await this._securityExtension?.checkAuthorization({ + types: new Set([type]), + spaces: new Set([namespaceString]), // Always check authZ for the active space + actions: new Set(['delete']), + }); + if (authorizationResult) { + this._securityExtension!.enforceAuthorization({ + typesAndSpaces, + action: 'delete', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => { + this._securityExtension!.addAuditEvent({ + action: AuditAction.DELETE, + savedObject: { type, id }, + error, + ...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the delete operation has not occurred yet + }); + }, + }); + } const rawId = this._serializer.generateRawId(namespace, type, id); let preflightResult: PreflightCheckNamespacesResult | undefined; @@ -900,7 +1085,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { namespaces = actualResult!._source.namespaces ?? [ SavedObjectsUtils.namespaceIdToString(namespace), ]; - const useForce = force && force === true ? true : false; + const useForce = force && force === true; // the document is shared to more than one space and can only be deleted by force. if (!useForce && (namespaces.length > 1 || namespaces.includes(ALL_NAMESPACES_STRING))) { return { @@ -911,7 +1096,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { type, error: errorContent( SavedObjectsErrorHelpers.createBadRequestError( - `Unable to delete saved object that exists in multiple namespaces, use the "force" option to delete it anyway` + 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ) ), }, @@ -940,14 +1125,18 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { options: SavedObjectsBulkDeleteOptions = {} ): Promise { const { refresh = DEFAULT_REFRESH_SETTING, force } = options; - const namespace = normalizeNamespace(options.namespace); + const namespace = this.getCurrentNamespace(options.namespace); const expectedBulkGetResults = this.presortObjectsByNamespaceType(objects); + if (expectedBulkGetResults.length === 0) { + return { statuses: [] }; + } + const multiNamespaceDocsResponse = await this.preflightCheckForBulkDelete({ expectedBulkGetResults, namespace, }); - const bulkDeleteParams: BulkDeleteParams[] = []; + // First round of filtering (Left: object doesn't exist/doesn't exist in namespace, Right: good to proceed) const expectedBulkDeleteMultiNamespaceDocsResults = this.getExpectedBulkDeleteMultiNamespaceDocsResults({ expectedBulkGetResults, @@ -955,21 +1144,76 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { namespace, force, }); - // bulk up the bulkDeleteParams - expectedBulkDeleteMultiNamespaceDocsResults.map((expectedResult) => { - if (isRight(expectedResult)) { - bulkDeleteParams.push({ - delete: { - _id: this._serializer.generateRawId( - namespace, - expectedResult.value.type, - expectedResult.value.id - ), - _index: this.getIndexForType(expectedResult.value.type), - ...getExpectedVersionProperties(undefined), - }, - }); + + // Perform Auth Check (on both L/R, we'll deal with that later) + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + const typesAndSpaces = new Map>(); + const spacesToAuthorize = new Set([namespaceString]); // Always check authZ for the active space + if (this._securityExtension) { + for (const { value } of expectedBulkDeleteMultiNamespaceDocsResults) { + const index = (value as { esRequestIndex: number }).esRequestIndex; + const { type } = value; + const preflightResult = + index !== undefined ? multiNamespaceDocsResponse?.body.docs[index] : undefined; + + const spacesToEnforce = typesAndSpaces.get(type) ?? new Set([namespaceString]); // Always enforce authZ for the active space + typesAndSpaces.set(type, spacesToEnforce); + // @ts-expect-error MultiGetHit._source is optional + for (const space of preflightResult?._source?.namespaces ?? []) { + spacesToAuthorize.add(space); // existing namespaces are included + } } + } + + const authorizationResult = await this._securityExtension?.checkAuthorization({ + types: new Set(typesAndSpaces.keys()), + spaces: spacesToAuthorize, + actions: new Set(['bulk_delete']), + }); + if (authorizationResult) { + this._securityExtension!.enforceAuthorization({ + typesAndSpaces, + action: 'bulk_delete', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => { + for (const { value } of expectedBulkDeleteMultiNamespaceDocsResults) { + this._securityExtension!.addAuditEvent({ + action: AuditAction.DELETE, + savedObject: { type: value.type, id: value.id }, + error, + ...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the delete operation has not occurred yet + }); + } + }, + }); + } + + // Filter valid objects + const validObjects = expectedBulkDeleteMultiNamespaceDocsResults.filter(isRight); + if (validObjects.length === 0) { + // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. + const savedObjects = expectedBulkDeleteMultiNamespaceDocsResults + .filter(isLeft) + .map((expectedResult) => { + return { ...expectedResult.value, success: false }; + }); + return { statuses: [...savedObjects] }; + } + + // Create the bulkDeleteParams + const bulkDeleteParams: BulkDeleteParams[] = []; + validObjects.map((expectedResult) => { + bulkDeleteParams.push({ + delete: { + _id: this._serializer.generateRawId( + namespace, + expectedResult.value.type, + expectedResult.value.id + ), + _index: this.getIndexForType(expectedResult.value.type), + ...getExpectedVersionProperties(undefined), + }, + }); }); const bulkDeleteResponse = bulkDeleteParams.length @@ -1040,7 +1284,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { }); // Delete aliases if necessary, ensuring we don't have too many concurrent operations running. - const mapper = async ({ type, id, namespaces, deleteBehavior }: ObjectToDeleteAliasesFor) => + const mapper = async ({ type, id, namespaces, deleteBehavior }: ObjectToDeleteAliasesFor) => { await deleteLegacyUrlAliases({ mappings: this._mappings, registry: this._registry, @@ -1053,6 +1297,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { }).catch((err) => { this._logger.error(`Unable to delete aliases when deleting an object: ${err.message}`); }); + }; await pMap(objectsToDeleteAliasesFor, mapper, { concurrency: MAX_CONCURRENT_ALIAS_DELETIONS }); return { statuses: [...savedObjects] }; @@ -1065,6 +1310,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { namespace: string, options: SavedObjectsDeleteByNamespaceOptions = {} ): Promise { + // This is not exposed on the SOC; authorization and audit logging is handled by the Spaces plugin if (!namespace || typeof namespace !== 'string' || namespace === '*') { throw new TypeError(`namespace is required, and must be a string that is not equal to '*'`); } @@ -1122,8 +1368,20 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { * {@inheritDoc ISavedObjectsRepository.find} */ async find( - options: SavedObjectsFindOptions + options: SavedObjectsFindOptions, + internalOptions: SavedObjectsFindInternalOptions = {} ): Promise> { + let namespaces!: string[]; + const { disableExtensions } = internalOptions; + if (disableExtensions || !this._spacesExtension) { + namespaces = options.namespaces ?? [DEFAULT_NAMESPACE_STRING]; + // If the consumer specified `namespaces: []`, throw a Bad Request error + if (namespaces.length === 0) + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.namespaces cannot be an empty array' + ); + } + const { search, defaultSearchOperator = 'OR', @@ -1140,41 +1398,23 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { sortField, sortOrder, fields, - namespaces, type, - typeToNamespacesMap, filter, preference, aggs, } = options; - if (!type && !typeToNamespacesMap) { + if (!type) { throw SavedObjectsErrorHelpers.createBadRequestError( 'options.type must be a string or an array of strings' ); - } else if (namespaces?.length === 0 && !typeToNamespacesMap) { - throw SavedObjectsErrorHelpers.createBadRequestError( - 'options.namespaces cannot be an empty array' - ); - } else if (type && typeToNamespacesMap) { - throw SavedObjectsErrorHelpers.createBadRequestError( - 'options.type must be an empty string when options.typeToNamespacesMap is used' - ); - } else if ((!namespaces || namespaces?.length) && typeToNamespacesMap) { - throw SavedObjectsErrorHelpers.createBadRequestError( - 'options.namespaces must be an empty array when options.typeToNamespacesMap is used' - ); } else if (preference?.length && pit) { throw SavedObjectsErrorHelpers.createBadRequestError( 'options.preference must be excluded when options.pit is used' ); } - const types = type - ? Array.isArray(type) - ? type - : [type] - : Array.from(typeToNamespacesMap!.keys()); + const types = Array.isArray(type) ? type : [type]; const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); if (allowedTypes.length === 0) { return SavedObjectsUtils.createEmptyFindResponse(options); @@ -1210,6 +1450,54 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { } } + if (!disableExtensions && this._spacesExtension) { + try { + namespaces = await this._spacesExtension.getSearchableNamespaces(options.namespaces); + } catch (err) { + if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { + // The user is not authorized to access any space, return an empty response. + return SavedObjectsUtils.createEmptyFindResponse(options); + } + throw err; + } + if (namespaces.length === 0) { + // The user is authorized to access *at least one space*, but not any of the spaces they requested; return an empty response. + return SavedObjectsUtils.createEmptyFindResponse(options); + } + } + + // We have to first do a "pre-authorization" check so that we can construct the search DSL accordingly + const spacesToPreauthorize = new Set(namespaces); + let typeToNamespacesMap: Map | undefined; + let preAuthorizationResult: CheckAuthorizationResult<'find'> | undefined; + if (!disableExtensions && this._securityExtension) { + preAuthorizationResult = await this._securityExtension.checkAuthorization({ + types: new Set(types), + spaces: spacesToPreauthorize, + actions: new Set(['find']), + }); + if (preAuthorizationResult.status === 'unauthorized') { + // If the user is unauthorized to find *anything* they requested, return an empty response + this._securityExtension.addAuditEvent({ + action: AuditAction.FIND, + error: new Error(`User is unauthorized for any requested types/spaces.`), + // TODO: include object type(s) that were requested? + // requestedTypes: types, + // requestedSpaces: namespaces, + }); + return SavedObjectsUtils.createEmptyFindResponse(options); + } + if (preAuthorizationResult.status === 'partially_authorized') { + typeToNamespacesMap = new Map(); + for (const [objType, entry] of preAuthorizationResult.typeMap) { + if (!entry.find) continue; + // This ensures that the query DSL can filter only for object types that the user is authorized to access for a given space + const { authorizedSpaces, isGloballyAuthorized } = entry.find; + typeToNamespacesMap.set(objType, isGloballyAuthorized ? namespaces : authorizedSpaces); + } + } + } + const esOptions = { // If `pit` is provided, we drop the `index`, otherwise ES returns 400. index: pit ? undefined : this.getIndicesForTypes(allowedTypes), @@ -1236,7 +1524,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { sortField, sortOrder, namespaces, - typeToNamespacesMap, + typeToNamespacesMap, // If defined, this takes precedence over the `type` and `namespaces` fields hasReference, hasReferenceOperator, hasNoReference, @@ -1259,15 +1547,10 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { } // 404 is only possible here if the index is missing, which // we don't want to leak, see "404s from missing index" above - return { - page, - per_page: perPage, - total: 0, - saved_objects: [], - }; + return SavedObjectsUtils.createEmptyFindResponse(options); } - return { + const result = { ...(body.aggregations ? { aggregations: body.aggregations as unknown as A } : {}), page, per_page: perPage, @@ -1283,6 +1566,36 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { ), pit_id: body.pit_id, } as SavedObjectsFindResponse; + + if (disableExtensions) { + return result; + } + + const spacesToAuthorize = new Set(spacesToPreauthorize); // only for namespace redaction + for (const { type: objType, id, namespaces: objectNamespaces = [] } of result.saved_objects) { + for (const space of objectNamespaces) { + spacesToAuthorize.add(space); + } + this._securityExtension?.addAuditEvent({ + action: AuditAction.FIND, + savedObject: { type: objType, id }, + }); + } + const authorizationResult = + spacesToAuthorize.size > spacesToPreauthorize.size + ? // If there are any namespaces in the object results that were not already checked during pre-authorization, we need *another* + // authorization check so we can correctly redact the object namespaces below. + await this._securityExtension?.checkAuthorization({ + types: new Set(types), + spaces: spacesToAuthorize, + actions: new Set(['find']), + }) + : undefined; + + return this.optionallyDecryptAndRedactBulkResult( + result, + authorizationResult?.typeMap ?? preAuthorizationResult?.typeMap // If we made a second authorization check, use that one; otherwise, fall back to the pre-authorization check + ); } /** @@ -1292,23 +1605,44 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: SavedObjectsBulkGetObject[] = [], options: SavedObjectsBaseOptions = {} ): Promise> { - const namespace = normalizeNamespace(options.namespace); + const namespace = this.getCurrentNamespace(options.namespace); if (objects.length === 0) { return { saved_objects: [] }; } + let availableSpacesPromise: Promise | undefined; + const getAvailableSpaces = async (spacesExtension: ISavedObjectsSpacesExtension) => { + if (!availableSpacesPromise) { + availableSpacesPromise = spacesExtension + .getSearchableNamespaces([ALL_NAMESPACES_STRING]) + .catch((err) => { + if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { + // the user doesn't have access to any spaces; return the current space ID and allow the SOR authZ check to fail + return [SavedObjectsUtils.namespaceIdToString(namespace)]; + } else { + throw err; + } + }); + } + return availableSpacesPromise; + }; + let bulkGetRequestIndexCounter = 0; - const expectedBulkGetResults: Array, Record>> = - objects.map((object) => { - const { type, id, fields, namespaces } = object; + type ExpectedBulkGetResult = Either< + { type: string; id: string; error: Payload }, + { type: string; id: string; fields?: string[]; namespaces?: string[]; esRequestIndex: number } + >; + const expectedBulkGetResults = await Promise.all( + objects.map>(async (object) => { + const { type, id, fields } = object; let error: DecoratedError | undefined; if (!this._allowedTypes.includes(type)) { error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } else { try { - this.validateObjectNamespaces(type, id, namespaces); + this.validateObjectNamespaces(type, id, object.namespaces); } catch (e) { error = e; } @@ -1321,6 +1655,10 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { }; } + let namespaces = object.namespaces; + if (this._spacesExtension && namespaces?.includes(ALL_NAMESPACES_STRING)) { + namespaces = await getAvailableSpaces(this._spacesExtension); + } return { tag: 'Right', value: { @@ -1331,17 +1669,27 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { esRequestIndex: bulkGetRequestIndexCounter++, }, }; - }); + }) + ); + + const validObjects = expectedBulkGetResults.filter(isRight); + if (validObjects.length === 0) { + // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. + return { + // Technically the returned array should only contain SavedObject results, but for errors this is not true (we cast to 'any' below) + saved_objects: expectedBulkGetResults.map>( + ({ value }) => value as unknown as SavedObject + ), + }; + } const getNamespaceId = (namespaces?: string[]) => namespaces !== undefined ? SavedObjectsUtils.namespaceStringToId(namespaces[0]) : namespace; - const bulkGetDocs = expectedBulkGetResults - .filter(isRight) - .map(({ value: { type, id, fields, namespaces } }) => ({ - _id: this._serializer.generateRawId(getNamespaceId(namespaces), type, id), // the namespace prefix is only used for single-namespace object types - _index: this.getIndexForType(type), - _source: { includes: includedFields(type, fields) }, - })); + const bulkGetDocs = validObjects.map(({ value: { type, id, fields, namespaces } }) => ({ + _id: this._serializer.generateRawId(getNamespaceId(namespaces), type, id), // the namespace prefix is only used for single-namespace object types + _index: this.getIndexForType(type), + _source: { includes: includedFields(type, fields) }, + })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( { @@ -1363,7 +1711,9 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); } - return { + const typesAndSpaces = new Map>(); + const spacesToAuthorize = new Set([SavedObjectsUtils.namespaceIdToString(namespace)]); // Always check authZ for the active space + const result = { saved_objects: expectedBulkGetResults.map((expectedResult) => { if (isLeft(expectedResult)) { return expectedResult.value as any; @@ -1372,11 +1722,24 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { const { type, id, - namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)], + namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)], // set to default value for `rawDocExistsInNamespaces` check below esRequestIndex, } = expectedResult.value; const doc = bulkGetResponse?.body.docs[esRequestIndex]; + const spacesToEnforce = + typesAndSpaces.get(type) ?? new Set([SavedObjectsUtils.namespaceIdToString(namespace)]); // Always enforce authZ for the active space + for (const space of namespaces) { + spacesToEnforce.add(space); + typesAndSpaces.set(type, spacesToEnforce); + spacesToAuthorize.add(space); + } + + // @ts-expect-error MultiGetHit._source is optional + for (const space of doc?._source?.namespaces ?? []) { + spacesToAuthorize.add(space); // existing namespaces are included so we can later redact if necessary + } + // @ts-expect-error MultiGetHit._source is optional if (!doc?.found || !this.rawDocExistsInNamespaces(doc, namespaces)) { return { @@ -1390,6 +1753,31 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { return getSavedObjectFromSource(this._registry, type, id, doc); }), }; + + const authorizationResult = await this._securityExtension?.checkAuthorization({ + types: new Set(typesAndSpaces.keys()), + spaces: spacesToAuthorize, + actions: new Set(['bulk_get']), + }); + if (authorizationResult) { + this._securityExtension!.enforceAuthorization({ + typesAndSpaces, + action: 'bulk_get', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => { + for (const { type, id, error: bulkError } of result.saved_objects) { + if (!error && !!bulkError) continue; // Only log success events for objects that were actually found (and are being returned to the user) + this._securityExtension!.addAuditEvent({ + action: AuditAction.GET, + savedObject: { type, id }, + error, + }); + } + }, + }); + } + + return this.optionallyDecryptAndRedactBulkResult(result, authorizationResult?.typeMap); } /** @@ -1399,6 +1787,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: SavedObjectsBulkResolveObject[], options: SavedObjectsBaseOptions = {} ): Promise> { + const namespace = this.getCurrentNamespace(options.namespace); const { resolved_objects: bulkResults } = await internalBulkResolve({ registry: this._registry, allowedTypes: this._allowedTypes, @@ -1406,12 +1795,14 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { serializer: this._serializer, getIndexForType: this.getIndexForType.bind(this), incrementCounterInternal: this.incrementCounterInternal.bind(this), + encryptionExtension: this._encryptionExtension, + securityExtension: this._securityExtension, objects, - options, + options: { ...options, namespace }, }); const resolvedObjects = bulkResults.map>((result) => { // extract payloads from saved object errors - if ((result as InternalBulkResolveError).error) { + if (isBulkResolveError(result)) { const errorResult = result as InternalBulkResolveError; const { type, id, error } = errorResult; return { @@ -1419,7 +1810,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { outcome: 'exactMatch', }; } - return result as SavedObjectsResolveResponse; + return result; }); return { resolved_objects: resolvedObjects }; } @@ -1432,10 +1823,11 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { id: string, options: SavedObjectsBaseOptions = {} ): Promise> { + const namespace = this.getCurrentNamespace(options.namespace); + if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const namespace = normalizeNamespace(options.namespace); const { body, statusCode, headers } = await this.client.get( { id: this._serializer.generateRawId(namespace, type, id), @@ -1449,6 +1841,31 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); } + const spacesToEnforce = new Set([SavedObjectsUtils.namespaceIdToString(namespace)]); // Always check/enforce authZ for the active space + const existingNamespaces = body?._source?.namespaces || []; + const authorizationResult = await this._securityExtension?.checkAuthorization({ + types: new Set([type]), + spaces: new Set([...spacesToEnforce, ...existingNamespaces]), // existing namespaces are included so we can later redact if necessary + actions: new Set(['get']), + }); + if (authorizationResult) { + this._securityExtension!.enforceAuthorization({ + typesAndSpaces: new Map([[type, spacesToEnforce]]), + action: 'get', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => { + if (error) { + this._securityExtension!.addAuditEvent({ + action: AuditAction.GET, + savedObject: { type, id }, + error, + }); + } + // Audit event for success case is added separately below + }, + }); + } + if ( !isFoundGetResponse(body) || indexNotFound || @@ -1457,7 +1874,15 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return getSavedObjectFromSource(this._registry, type, id, body); + // Only log a success event if the object was actually found (and is being returned to the user) + this._securityExtension?.addAuditEvent({ + action: AuditAction.GET, + savedObject: { type, id }, + }); + + const result = getSavedObjectFromSource(this._registry, type, id, body); + + return this.optionallyDecryptAndRedactSingleResult(result, authorizationResult?.typeMap); } /** @@ -1468,6 +1893,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { id: string, options: SavedObjectsBaseOptions = {} ): Promise> { + const namespace = this.getCurrentNamespace(options.namespace); const { resolved_objects: bulkResults } = await internalBulkResolve({ registry: this._registry, allowedTypes: this._allowedTypes, @@ -1475,14 +1901,16 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { serializer: this._serializer, getIndexForType: this.getIndexForType.bind(this), incrementCounterInternal: this.incrementCounterInternal.bind(this), + encryptionExtension: this._encryptionExtension, + securityExtension: this._securityExtension, objects: [{ type, id }], - options, + options: { ...options, namespace }, }); const [result] = bulkResults; - if ((result as InternalBulkResolveError).error) { - throw (result as InternalBulkResolveError).error; + if (isBulkResolveError(result)) { + throw result.error; } - return result as SavedObjectsResolveResponse; + return result; } /** @@ -1494,6 +1922,8 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { attributes: Partial, options: SavedObjectsUpdateOptions = {} ): Promise> { + const namespace = this.getCurrentNamespace(options.namespace); + if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -1508,7 +1938,6 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { refresh = DEFAULT_REFRESH_SETTING, retryOnConflict = version ? 0 : DEFAULT_RETRY_COUNT, } = options; - const namespace = normalizeNamespace(options.namespace); let preflightResult: PreflightCheckNamespacesResult | undefined; if (this._registry.isMultiNamespace(type)) { @@ -1517,20 +1946,42 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { id, namespace, }); - if ( - preflightResult.checkResult === 'found_outside_namespace' || - (!upsert && preflightResult.checkResult === 'not_found') - ) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - if (upsert && preflightResult.checkResult === 'not_found') { - // If an upsert would result in the creation of a new object, we need to check for alias conflicts too. - // This takes an extra round trip to Elasticsearch, but this won't happen often. - // TODO: improve performance by combining these into a single preflight check - await this.preflightCheckForUpsertAliasConflict(type, id, namespace); - } } + const spacesToEnforce = new Set([SavedObjectsUtils.namespaceIdToString(namespace)]); // Always check/enforce authZ for the active space + const existingNamespaces = preflightResult?.savedObjectNamespaces || []; + const authorizationResult = await this._securityExtension?.checkAuthorization({ + types: new Set([type]), + spaces: new Set([...spacesToEnforce, ...existingNamespaces]), // existing namespaces are included so we can later redact if necessary + actions: new Set(['update']), + }); + if (authorizationResult) { + this._securityExtension!.enforceAuthorization({ + typesAndSpaces: new Map([[type, spacesToEnforce]]), + action: 'update', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => + this._securityExtension!.addAuditEvent({ + action: AuditAction.UPDATE, + savedObject: { type, id }, + error, + ...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update/upsert operation has not occurred yet + }), + }); + } + + if ( + preflightResult?.checkResult === 'found_outside_namespace' || + (!upsert && preflightResult?.checkResult === 'not_found') + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + if (upsert && preflightResult?.checkResult === 'not_found') { + // If an upsert would result in the creation of a new object, we need to check for alias conflicts too. + // This takes an extra round trip to Elasticsearch, but this won't happen often. + // TODO: improve performance by combining these into a single preflight check + await this.preflightCheckForUpsertAliasConflict(type, id, namespace); + } const time = getCurrentTime(); let rawUpsert: SavedObjectsRawDoc | undefined; @@ -1551,7 +2002,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), attributes: { - ...upsert, + ...(await this.optionallyEncryptAttributes(type, id, options.namespace, upsert)), }, updated_at: time, }); @@ -1559,7 +2010,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { } const doc = { - [type]: attributes, + [type]: await this.optionallyEncryptAttributes(type, id, options.namespace, attributes), updated_at: time, ...(Array.isArray(references) && { references }), }; @@ -1597,7 +2048,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { ]; } - return { + const result = { id, type, updated_at: time, @@ -1606,7 +2057,13 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { ...(originId && { originId }), references, attributes, - }; + } as SavedObject; + + return this.optionallyDecryptAndRedactSingleResult( + result, + authorizationResult?.typeMap, + attributes + ); } /** @@ -1614,8 +2071,9 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { */ async collectMultiNamespaceReferences( objects: SavedObjectsCollectMultiNamespaceReferencesObject[], - options?: SavedObjectsCollectMultiNamespaceReferencesOptions + options: SavedObjectsCollectMultiNamespaceReferencesOptions = {} ) { + const namespace = this.getCurrentNamespace(options.namespace); return collectMultiNamespaceReferences({ registry: this._registry, allowedTypes: this._allowedTypes, @@ -1623,8 +2081,9 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { serializer: this._serializer, getIndexForType: this.getIndexForType.bind(this), createPointInTimeFinder: this.createPointInTimeFinder.bind(this), + securityExtension: this._securityExtension, objects, - options, + options: { ...options, namespace }, }); } @@ -1635,8 +2094,9 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: SavedObjectsUpdateObjectsSpacesObject[], spacesToAdd: string[], spacesToRemove: string[], - options?: SavedObjectsUpdateObjectsSpacesOptions + options: SavedObjectsUpdateObjectsSpacesOptions = {} ) { + const namespace = this.getCurrentNamespace(options.namespace); return updateObjectsSpaces({ mappings: this._mappings, registry: this._registry, @@ -1645,10 +2105,11 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { serializer: this._serializer, logger: this._logger, getIndexForType: this.getIndexForType.bind(this), + securityExtension: this._securityExtension, objects, spacesToAdd, spacesToRemove, - options, + options: { ...options, namespace }, }); } @@ -1659,72 +2120,86 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: Array>, options: SavedObjectsBulkUpdateOptions = {} ): Promise> { + const namespace = this.getCurrentNamespace(options.namespace); const time = getCurrentTime(); - const namespace = normalizeNamespace(options.namespace); let bulkGetRequestIndexCounter = 0; - const expectedBulkGetResults: Array, Record>> = - objects.map((object) => { - const { type, id } = object; - - if (!this._allowedTypes.includes(type)) { - return { - tag: 'Left', - value: { - id, - type, - error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), - }, - }; + type DocumentToSave = Record; + type ExpectedBulkGetResult = Either< + { type: string; id: string; error: Payload }, + { + type: string; + id: string; + version?: string; + documentToSave: DocumentToSave; + objectNamespace?: string; + esRequestIndex?: number; + } + >; + const expectedBulkGetResults = objects.map((object) => { + const { type, id, attributes, references, version, namespace: objectNamespace } = object; + let error: DecoratedError | undefined; + if (!this._allowedTypes.includes(type)) { + error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } else { + try { + if (objectNamespace === ALL_NAMESPACES_STRING) { + error = SavedObjectsErrorHelpers.createBadRequestError('"namespace" cannot be "*"'); + } + } catch (e) { + error = e; } + } - const { attributes, references, version, namespace: objectNamespace } = object; + // `objectNamespace` is a namespace string, while `namespace` is a namespace ID. + // The object namespace string, if defined, will supersede the operation's namespace ID. - if (objectNamespace === ALL_NAMESPACES_STRING) { - return { - tag: 'Left', - value: { - id, - type, - error: errorContent( - SavedObjectsErrorHelpers.createBadRequestError('"namespace" cannot be "*"') - ), - }, - }; - } - // `objectNamespace` is a namespace string, while `namespace` is a namespace ID. - // The object namespace string, if defined, will supersede the operation's namespace ID. - - const documentToSave = { - [type]: attributes, - updated_at: time, - ...(Array.isArray(references) && { references }), + if (error) { + return { + tag: 'Left', + value: { id, type, error: errorContent(error) }, }; + } - const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type); + const documentToSave = { + [type]: attributes, + updated_at: time, + ...(Array.isArray(references) && { references }), + }; - return { - tag: 'Right', - value: { - type, - id, - version, - documentToSave, - objectNamespace, - ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), - }, - }; - }); + const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type); + + return { + tag: 'Right', + value: { + type, + id, + version, + documentToSave, + objectNamespace, + ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + }, + }; + }); + const validObjects = expectedBulkGetResults.filter(isRight); + if (validObjects.length === 0) { + // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. + return { + // Technically the returned array should only contain SavedObject results, but for errors this is not true (we cast to 'any' below) + saved_objects: expectedBulkGetResults.map>( + ({ value }) => value as unknown as SavedObject + ), + }; + } + + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); const getNamespaceId = (objectNamespace?: string) => objectNamespace !== undefined ? SavedObjectsUtils.namespaceStringToId(objectNamespace) : namespace; - const getNamespaceString = (objectNamespace?: string) => - objectNamespace ?? SavedObjectsUtils.namespaceIdToString(namespace); - - const bulkGetDocs = expectedBulkGetResults - .filter(isRight) + const getNamespaceString = (objectNamespace?: string) => objectNamespace ?? namespaceString; + const bulkGetDocs = validObjects .filter(({ value }) => value.esRequestIndex !== undefined) .map(({ value: { type, id, objectNamespace } }) => ({ _id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id), @@ -1732,17 +2207,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { _source: ['type', 'namespaces'], })); const bulkGetResponse = bulkGetDocs.length - ? await this.client.mget( - { - body: { - docs: bulkGetDocs, - }, - }, - { - ignore: [404], - meta: true, - } - ) + ? await this.client.mget({ body: { docs: bulkGetDocs } }, { ignore: [404], meta: true }) : undefined; // fail fast if we can't verify a 404 response is from Elasticsearch if ( @@ -1755,72 +2220,139 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); } - let bulkUpdateRequestIndexCounter = 0; - const bulkUpdateParams: object[] = []; - const expectedBulkUpdateResults: Array, Record>> = - expectedBulkGetResults.map((expectedBulkGetResult) => { - if (isLeft(expectedBulkGetResult)) { - return expectedBulkGetResult; - } + const typesAndSpaces = new Map>(); + const spacesToAuthorize = new Set([namespaceString]); // Always check authZ for the active space + for (const { value } of validObjects) { + const { type, objectNamespace, esRequestIndex: index } = value; + const objectNamespaceString = getNamespaceString(objectNamespace); + const preflightResult = index !== undefined ? bulkGetResponse?.body.docs[index] : undefined; + + const spacesToEnforce = typesAndSpaces.get(type) ?? new Set([namespaceString]); // Always enforce authZ for the active space + spacesToEnforce.add(objectNamespaceString); + typesAndSpaces.set(type, spacesToEnforce); + spacesToAuthorize.add(objectNamespaceString); + // @ts-expect-error MultiGetHit._source is optional + for (const space of preflightResult?._source?.namespaces ?? []) { + spacesToAuthorize.add(space); // existing namespaces are included so we can later redact if necessary + } + } - const { esRequestIndex, id, type, version, documentToSave, objectNamespace } = - expectedBulkGetResult.value; + const authorizationResult = await this._securityExtension?.checkAuthorization({ + types: new Set(typesAndSpaces.keys()), + spaces: spacesToAuthorize, + actions: new Set(['bulk_update']), + }); + if (authorizationResult) { + this._securityExtension!.enforceAuthorization({ + typesAndSpaces, + action: 'bulk_update', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => { + for (const { value } of validObjects) { + this._securityExtension!.addAuditEvent({ + action: AuditAction.UPDATE, + savedObject: { type: value.type, id: value.id }, + error, + ...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update operation has not occurred yet + }); + } + }, + }); + } - let namespaces; - let versionProperties; - if (esRequestIndex !== undefined) { - const indexFound = bulkGetResponse?.statusCode !== 404; - const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; - const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; - if ( - !docFound || - // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - !this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace)) - ) { - return { - tag: 'Left', - value: { - id, - type, - error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), - }, - }; + let bulkUpdateRequestIndexCounter = 0; + const bulkUpdateParams: object[] = []; + type ExpectedBulkUpdateResult = Either< + { type: string; id: string; error: Payload }, + { + type: string; + id: string; + namespaces: string[]; + documentToSave: DocumentToSave; + esRequestIndex: number; + } + >; + const expectedBulkUpdateResults = await Promise.all( + expectedBulkGetResults.map>( + async (expectedBulkGetResult) => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; } - // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - namespaces = actualResult!._source.namespaces ?? [ + + const { esRequestIndex, id, type, version, documentToSave, objectNamespace } = + expectedBulkGetResult.value; + + let namespaces; + let versionProperties; + if (esRequestIndex !== undefined) { + const indexFound = bulkGetResponse?.statusCode !== 404; + const actualResult = indexFound + ? bulkGetResponse?.body.docs[esRequestIndex] + : undefined; + const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; + if ( + !docFound || + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + !this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace)) + ) { + return { + tag: 'Left', + value: { + id, + type, + error: errorContent( + SavedObjectsErrorHelpers.createGenericNotFoundError(type, id) + ), + }, + }; + } // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), - ]; - versionProperties = getExpectedVersionProperties(version); - } else { - if (this._registry.isSingleNamespace(type)) { - // if `objectNamespace` is undefined, fall back to `options.namespace` - namespaces = [getNamespaceString(objectNamespace)]; + namespaces = actualResult!._source.namespaces ?? [ + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), + ]; + versionProperties = getExpectedVersionProperties(version); + } else { + if (this._registry.isSingleNamespace(type)) { + // if `objectNamespace` is undefined, fall back to `options.namespace` + namespaces = [getNamespaceString(objectNamespace)]; + } + versionProperties = getExpectedVersionProperties(version); } - versionProperties = getExpectedVersionProperties(version); - } - const expectedResult = { - type, - id, - namespaces, - esRequestIndex: bulkUpdateRequestIndexCounter++, - documentToSave: expectedBulkGetResult.value.documentToSave, - }; + const expectedResult = { + type, + id, + namespaces, + esRequestIndex: bulkUpdateRequestIndexCounter++, + documentToSave: expectedBulkGetResult.value.documentToSave, + }; - bulkUpdateParams.push( - { - update: { - _id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id), - _index: this.getIndexForType(type), - ...versionProperties, + bulkUpdateParams.push( + { + update: { + _id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id), + _index: this.getIndexForType(type), + ...versionProperties, + }, }, - }, - { doc: documentToSave } - ); + { + doc: { + ...documentToSave, + [type]: await this.optionallyEncryptAttributes( + type, + id, + objectNamespace || namespace, + documentToSave[type] + ), + }, + } + ); - return { tag: 'Right', value: expectedResult }; - }); + return { tag: 'Right', value: expectedResult }; + } + ) + ); const { refresh = DEFAULT_REFRESH_SETTING } = options; const bulkUpdateResponse = bulkUpdateParams.length @@ -1832,7 +2364,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { }) : undefined; - return { + const result = { saved_objects: expectedBulkUpdateResults.map((expectedResult) => { if (isLeft(expectedResult)) { return expectedResult.value as any; @@ -1867,6 +2399,8 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { }; }), }; + + return this.optionallyDecryptAndRedactBulkResult(result, authorizationResult?.typeMap, objects); } /** @@ -1877,7 +2411,32 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { id: string, options: SavedObjectsRemoveReferencesToOptions = {} ): Promise { - const { namespace, refresh = true } = options; + const namespace = this.getCurrentNamespace(options.namespace); + const { refresh = true } = options; + + // TODO: Improve authorization and auditing (https://github.com/elastic/kibana/issues/135259) + + const spaces = new Set([SavedObjectsUtils.namespaceIdToString(namespace)]); // Always check/enforce authZ for the active space + const authorizationResult = await this._securityExtension?.checkAuthorization({ + types: new Set([type]), + spaces, + actions: new Set(['delete']), + }); + if (authorizationResult) { + this._securityExtension!.enforceAuthorization({ + typesAndSpaces: new Map([[type, spaces]]), + action: 'delete', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => + this._securityExtension!.addAuditEvent({ + action: AuditAction.REMOVE_REFERENCES, + savedObject: { type, id }, + error, + ...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the updateByQuery operation has not occurred yet + }), + }); + } + const allTypes = this._registry.getAllTypes().map((t) => t.name); // we need to target all SO indices as all types of objects may have references to the given SO. @@ -1943,6 +2502,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { counterFields: Array, options?: SavedObjectsIncrementCounterOptions ) { + // This is not exposed on the SOC, there are no authorization or audit logging checks if (typeof type !== 'string') { throw new Error('"type" argument must be a string'); } @@ -2109,14 +2669,70 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { */ async openPointInTimeForType( type: string | string[], - { keepAlive = '5m', preference }: SavedObjectsOpenPointInTimeOptions = {} + options: SavedObjectsOpenPointInTimeOptions = {}, + internalOptions: SavedObjectsFindInternalOptions = {} ): Promise { + const { disableExtensions } = internalOptions; + let namespaces!: string[]; + if (disableExtensions || !this._spacesExtension) { + namespaces = options.namespaces ?? [DEFAULT_NAMESPACE_STRING]; + // If the consumer specified `namespaces: []`, throw a Bad Request error + if (namespaces.length === 0) + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.namespaces cannot be an empty array' + ); + } + + const { keepAlive = '5m', preference } = options; const types = Array.isArray(type) ? type : [type]; const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); if (allowedTypes.length === 0) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(); } + if (!disableExtensions && this._spacesExtension) { + try { + namespaces = await this._spacesExtension.getSearchableNamespaces(options.namespaces); + } catch (err) { + if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { + // The user is not authorized to access any space, throw a bad request error. + throw SavedObjectsErrorHelpers.createBadRequestError(); + } + throw err; + } + if (namespaces.length === 0) { + // The user is authorized to access *at least one space*, but not any of the spaces they requested; throw a bad request error. + throw SavedObjectsErrorHelpers.createBadRequestError(); + } + } + + if (!disableExtensions && this._securityExtension) { + const spaces = new Set(namespaces); + const preAuthorizationResult = await this._securityExtension.checkAuthorization({ + types: new Set(types), + spaces, + actions: new Set(['open_point_in_time']), + }); + if (preAuthorizationResult.status === 'unauthorized') { + // If the user is unauthorized to find *anything* they requested, return an empty response + this._securityExtension.addAuditEvent({ + action: AuditAction.OPEN_POINT_IN_TIME, + error: new Error('User is unauthorized for any requested types/spaces.'), + // TODO: include object type(s) that were requested? + // requestedTypes: types, + // requestedSpaces: namespaces, + }); + throw SavedObjectsErrorHelpers.decorateForbiddenError(new Error('unauthorized')); + } + this._securityExtension.addAuditEvent({ + action: AuditAction.OPEN_POINT_IN_TIME, + outcome: 'unknown', + // TODO: include object type(s) that were requested? + // requestedTypes: types, + // requestedSpaces: namespaces, + }); + } + const esOptions = { index: this.getIndicesForTypes(allowedTypes), keep_alive: keepAlive, @@ -2146,8 +2762,18 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { */ async closePointInTime( id: string, - options?: SavedObjectsClosePointInTimeOptions + options?: SavedObjectsClosePointInTimeOptions, + internalOptions: SavedObjectsFindInternalOptions = {} ): Promise { + const { disableExtensions } = internalOptions; + + if (!disableExtensions && this._securityExtension) { + this._securityExtension.addAuditEvent({ + action: AuditAction.CLOSE_POINT_IN_TIME, + outcome: 'unknown', + }); + } + return await this.client.closePointInTime({ body: { id }, }); @@ -2158,12 +2784,14 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { */ createPointInTimeFinder( findOptions: SavedObjectsCreatePointInTimeFinderOptions, - dependencies?: SavedObjectsCreatePointInTimeFinderDependencies + dependencies?: SavedObjectsCreatePointInTimeFinderDependencies, + internalOptions?: SavedObjectsFindInternalOptions ): ISavedObjectsPointInTimeFinder { return new PointInTimeFinder(findOptions, { logger: this._logger, client: this, ...dependencies, + internalOptions, }); } @@ -2278,6 +2906,20 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { // any other error from this check does not matter } + /** + * If the spaces extension is enabled, we should use that to get the current namespace (and optionally throw an error if a consumer + * attempted to specify the namespace option). + * + * If the spaces extension is *not* enabled, we should simply normalize the namespace option so that `'default'` can be used + * interchangeably with `undefined`. + */ + private getCurrentNamespace(namespace?: string) { + if (this._spacesExtension) { + return this._spacesExtension.getCurrentNamespace(namespace); + } + return normalizeNamespace(namespace); + } + /** The `initialNamespaces` field (create, bulkCreate) is used to create an object in an initial set of spaces. */ private validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) { if (!initialNamespaces) { @@ -2355,6 +2997,95 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { ); } } + + /** + * Saved objects with encrypted attributes should have IDs that are hard to guess, especially since IDs are part of the AAD used during + * encryption, that's why we control them within this function and don't allow consumers to specify their own IDs directly for encryptable + * types unless overwriting the original document. + */ + private getValidId( + type: string, + id: string | undefined, + version: string | undefined, + overwrite: boolean | undefined + ) { + if (!this._encryptionExtension?.isEncryptableType(type)) { + return id || SavedObjectsUtils.generateId(); + } + if (!id) { + return SavedObjectsUtils.generateId(); + } + // only allow a specified ID if we're overwriting an existing ESO with a Version + // this helps us ensure that the document really was previously created using ESO + // and not being used to get around the specified ID limitation + const canSpecifyID = (overwrite && version) || SavedObjectsUtils.isRandomId(id); + if (!canSpecifyID) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.' + ); + } + return id; + } + + private async optionallyEncryptAttributes( + type: string, + id: string, + namespaceOrNamespaces: string | string[] | undefined, + attributes: T + ): Promise { + if (!this._encryptionExtension?.isEncryptableType(type)) { + return attributes; + } + const namespace = Array.isArray(namespaceOrNamespaces) + ? namespaceOrNamespaces[0] + : namespaceOrNamespaces; + const descriptor = { type, id, namespace }; + return this._encryptionExtension.encryptAttributes( + descriptor, + attributes as Record + ) as unknown as T; + } + + private async optionallyDecryptAndRedactSingleResult( + object: SavedObject, + typeMap: AuthorizationTypeMap | undefined, + originalAttributes?: T + ) { + if (this._encryptionExtension?.isEncryptableType(object.type)) { + object = await this._encryptionExtension.decryptOrStripResponseAttributes( + object, + originalAttributes + ); + } + if (typeMap) { + return this._securityExtension!.redactNamespaces({ typeMap, savedObject: object }); + } + return object; + } + + private async optionallyDecryptAndRedactBulkResult< + T, + R extends { saved_objects: Array> }, + A extends string, + O extends Array<{ attributes: T }> + >(response: R, typeMap: AuthorizationTypeMap | undefined, originalObjects?: O) { + const modifiedObjects = await Promise.all( + response.saved_objects.map(async (object, index) => { + if (object.error) { + // If the bulk operation failed, the object will not have an attributes field at all, it will have an error field instead. + // In this case, don't attempt to decrypt, just return the object. + return object; + } + const originalAttributes = originalObjects?.[index].attributes; + return await this.optionallyDecryptAndRedactSingleResult( + object, + typeMap, + originalAttributes + ); + }) + ); + return { ...response, saved_objects: modifiedObjects }; + } } /** diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_bulk_delete_internal_types.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_bulk_delete_internal_types.ts index 93d4354d8d7e8..6319662e8bb98 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_bulk_delete_internal_types.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_bulk_delete_internal_types.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ import type { Payload } from '@hapi/boom'; -import { +import type { BulkOperationBase, BulkResponseItem, ErrorCause, } from '@elastic/elasticsearch/lib/api/types'; import type { estypes, TransportResult } from '@elastic/elasticsearch'; -import { Either } from './internal_utils'; -import { DeleteLegacyUrlAliasesParams } from './legacy_url_aliases'; +import type { Either } from './internal_utils'; +import type { DeleteLegacyUrlAliasesParams } from './legacy_url_aliases'; /** * @internal diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_create_repository.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_create_repository.test.ts index 564021482714d..af4216bf4ecb6 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_create_repository.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_create_repository.test.ts @@ -90,6 +90,7 @@ describe('SavedObjectsRepository#createRepository', () => { callAdminCluster, logger, [], + undefined, SavedObjectsRepository ); expect(repository).toBeDefined(); @@ -109,6 +110,7 @@ describe('SavedObjectsRepository#createRepository', () => { callAdminCluster, logger, ['hiddenType', 'hiddenType', 'hiddenType'], + undefined, SavedObjectsRepository ); expect(repository).toBeDefined(); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/scoped_client_provider.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/scoped_client_provider.test.ts index 2641ecfb1cec6..03a554ae02fb3 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/scoped_client_provider.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/scoped_client_provider.test.ts @@ -5,17 +5,54 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import type { Optional } from 'utility-types'; import { httpServerMock } from '@kbn/core-http-server-mocks'; import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; import { SavedObjectsClientProvider } from './scoped_client_provider'; +import { + type ISavedObjectTypeRegistry, + type SavedObjectsClientFactory, + type SavedObjectsEncryptionExtensionFactory, + type SavedObjectsSecurityExtensionFactory, + type SavedObjectsSpacesExtensionFactory, + ENCRYPTION_EXTENSION_ID, + SECURITY_EXTENSION_ID, + SPACES_EXTENSION_ID, +} from '@kbn/core-saved-objects-server'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock'; + +/** + * @internal only used for unit tests + */ +interface Params { + defaultClientFactory: SavedObjectsClientFactory; + typeRegistry: ISavedObjectTypeRegistry; + encryptionExtensionFactory: SavedObjectsEncryptionExtensionFactory; + securityExtensionFactory: SavedObjectsSecurityExtensionFactory; + spacesExtensionFactory: SavedObjectsSpacesExtensionFactory; +} + +function createClientProvider( + params: Optional< + Params, + 'encryptionExtensionFactory' | 'securityExtensionFactory' | 'spacesExtensionFactory' + > +) { + return new SavedObjectsClientProvider({ + encryptionExtensionFactory: undefined, + securityExtensionFactory: undefined, + spacesExtensionFactory: undefined, + ...params, + }); +} test(`uses default client factory when one isn't set`, () => { const returnValue = Symbol(); const defaultClientFactoryMock = jest.fn().mockReturnValue(returnValue); const request = httpServerMock.createKibanaRequest(); - const clientProvider = new SavedObjectsClientProvider({ + const clientProvider = createClientProvider({ defaultClientFactory: defaultClientFactoryMock, typeRegistry: typeRegistryMock.create(), }); @@ -24,6 +61,7 @@ test(`uses default client factory when one isn't set`, () => { expect(result).toBe(returnValue); expect(defaultClientFactoryMock).toHaveBeenCalledTimes(1); expect(defaultClientFactoryMock).toHaveBeenCalledWith({ + extensions: expect.any(Object), request, }); }); @@ -34,7 +72,7 @@ test(`uses custom client factory when one is set`, () => { const returnValue = Symbol(); const customClientFactoryMock = jest.fn().mockReturnValue(returnValue); - const clientProvider = new SavedObjectsClientProvider({ + const clientProvider = createClientProvider({ defaultClientFactory: defaultClientFactoryMock, typeRegistry: typeRegistryMock.create(), }); @@ -45,6 +83,7 @@ test(`uses custom client factory when one is set`, () => { expect(defaultClientFactoryMock).not.toHaveBeenCalled(); expect(customClientFactoryMock).toHaveBeenCalledTimes(1); expect(customClientFactoryMock).toHaveBeenCalledWith({ + extensions: expect.any(Object), request, }); }); @@ -53,7 +92,7 @@ test(`throws error when more than one scoped saved objects client factory is set const defaultClientFactory = jest.fn(); const clientFactory = jest.fn(); - const clientProvider = new SavedObjectsClientProvider({ + const clientProvider = createClientProvider({ defaultClientFactory, typeRegistry: typeRegistryMock.create(), }); @@ -66,111 +105,114 @@ test(`throws error when more than one scoped saved objects client factory is set ); }); -test(`throws error when registering a wrapper with a duplicate id`, () => { - const defaultClientFactoryMock = jest.fn(); - const clientProvider = new SavedObjectsClientProvider({ - defaultClientFactory: defaultClientFactoryMock, - typeRegistry: typeRegistryMock.create(), - }); - const firstClientWrapperFactoryMock = jest.fn(); - const secondClientWrapperFactoryMock = jest.fn(); - - clientProvider.addClientWrapperFactory(1, 'foo', secondClientWrapperFactoryMock); - expect(() => - clientProvider.addClientWrapperFactory(0, 'foo', firstClientWrapperFactoryMock) - ).toThrowErrorMatchingInlineSnapshot(`"wrapper factory with id foo is already defined"`); -}); - -test(`invokes and uses wrappers in specified order`, () => { +describe(`allows extensions to be excluded`, () => { const defaultClient = Symbol(); const typeRegistry = typeRegistryMock.create(); const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient); - const clientProvider = new SavedObjectsClientProvider({ - defaultClientFactory: defaultClientFactoryMock, - typeRegistry, - }); - const firstWrappedClient = Symbol('first client'); - const firstClientWrapperFactoryMock = jest.fn().mockReturnValue(firstWrappedClient); - const secondWrapperClient = Symbol('second client'); - const secondClientWrapperFactoryMock = jest.fn().mockReturnValue(secondWrapperClient); - const request = httpServerMock.createKibanaRequest(); - - clientProvider.addClientWrapperFactory(1, 'foo', secondClientWrapperFactoryMock); - clientProvider.addClientWrapperFactory(0, 'bar', firstClientWrapperFactoryMock); - const actualClient = clientProvider.getClient(request); - expect(actualClient).toBe(firstWrappedClient); - expect(firstClientWrapperFactoryMock).toHaveBeenCalledWith({ - request, - client: secondWrapperClient, - typeRegistry, - }); - expect(secondClientWrapperFactoryMock).toHaveBeenCalledWith({ - request, - client: defaultClient, - typeRegistry, - }); -}); - -test(`does not invoke or use excluded wrappers`, () => { - const defaultClient = Symbol(); - const typeRegistry = typeRegistryMock.create(); - const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient); - const clientProvider = new SavedObjectsClientProvider({ + const mockEncryptionExt = savedObjectsExtensionsMock.createEncryptionExtension(); + const encryptionExtFactory: SavedObjectsEncryptionExtensionFactory = (params: { + typeRegistry: ISavedObjectTypeRegistry; + request: KibanaRequest; + }) => mockEncryptionExt; + + const mockSpacesExt = savedObjectsExtensionsMock.createSpacesExtension(); + const spacesExtFactory: SavedObjectsSpacesExtensionFactory = (params: { + typeRegistry: ISavedObjectTypeRegistry; + request: KibanaRequest; + }) => mockSpacesExt; + + const mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension(); + const securityExtFactory: SavedObjectsSecurityExtensionFactory = (params: { + typeRegistry: ISavedObjectTypeRegistry; + request: KibanaRequest; + }) => mockSecurityExt; + + const clientProvider = createClientProvider({ defaultClientFactory: defaultClientFactoryMock, + encryptionExtensionFactory: encryptionExtFactory, + spacesExtensionFactory: spacesExtFactory, + securityExtensionFactory: securityExtFactory, typeRegistry, }); - const firstWrappedClient = Symbol('first client'); - const firstClientWrapperFactoryMock = jest.fn().mockReturnValue(firstWrappedClient); - const secondWrapperClient = Symbol('second client'); - const secondClientWrapperFactoryMock = jest.fn().mockReturnValue(secondWrapperClient); - const request = httpServerMock.createKibanaRequest(); - - clientProvider.addClientWrapperFactory(1, 'foo', secondClientWrapperFactoryMock); - clientProvider.addClientWrapperFactory(0, 'bar', firstClientWrapperFactoryMock); - - const actualClient = clientProvider.getClient(request, { - excludedWrappers: ['foo'], - }); - expect(actualClient).toBe(firstWrappedClient); - expect(firstClientWrapperFactoryMock).toHaveBeenCalledWith({ - request, - client: defaultClient, - typeRegistry, + test(`calls client factory with all extensions excluded`, async () => { + const request = httpServerMock.createKibanaRequest(); + + clientProvider.getClient(request, { + excludedExtensions: [ENCRYPTION_EXTENSION_ID, SECURITY_EXTENSION_ID, SPACES_EXTENSION_ID], + }); + + expect(defaultClientFactoryMock).toHaveBeenCalledWith( + expect.objectContaining({ + extensions: { + encryptionExtension: undefined, + securityExtension: undefined, + spacesExtension: undefined, + }, + }) + ); + }); + + test(`calls client factory with some extensions excluded`, async () => { + const request = httpServerMock.createKibanaRequest(); + + clientProvider.getClient(request, { + excludedExtensions: [ENCRYPTION_EXTENSION_ID, SPACES_EXTENSION_ID], + }); + + expect(defaultClientFactoryMock).toHaveBeenCalledWith( + expect.objectContaining({ + extensions: { + encryptionExtension: undefined, + securityExtension: mockSecurityExt, + spacesExtension: undefined, + }, + }) + ); + }); + + test(`calls client factory with one extension excluded`, async () => { + const request = httpServerMock.createKibanaRequest(); + + clientProvider.getClient(request, { + excludedExtensions: [SECURITY_EXTENSION_ID], + }); + + expect(defaultClientFactoryMock).toHaveBeenCalledWith( + expect.objectContaining({ + extensions: { + encryptionExtension: mockEncryptionExt, + securityExtension: undefined, + spacesExtension: mockSpacesExt, + }, + }) + ); + }); + + test(`calls client factory with no extensions excluded`, async () => { + const request = httpServerMock.createKibanaRequest(); + + clientProvider.getClient(request, { + excludedExtensions: [], + }); + + expect(defaultClientFactoryMock).toHaveBeenCalledWith( + expect.objectContaining({ + extensions: { + encryptionExtension: mockEncryptionExt, + securityExtension: mockSecurityExt, + spacesExtension: mockSpacesExt, + }, + }) + ); }); - expect(secondClientWrapperFactoryMock).not.toHaveBeenCalled(); -}); - -test(`allows all wrappers to be excluded`, () => { - const defaultClient = Symbol(); - const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient); - const clientProvider = new SavedObjectsClientProvider({ - defaultClientFactory: defaultClientFactoryMock, - typeRegistry: typeRegistryMock.create(), - }); - const firstWrappedClient = Symbol('first client'); - const firstClientWrapperFactoryMock = jest.fn().mockReturnValue(firstWrappedClient); - const secondWrapperClient = Symbol('second client'); - const secondClientWrapperFactoryMock = jest.fn().mockReturnValue(secondWrapperClient); - const request = httpServerMock.createKibanaRequest(); - - clientProvider.addClientWrapperFactory(1, 'foo', secondClientWrapperFactoryMock); - clientProvider.addClientWrapperFactory(0, 'bar', firstClientWrapperFactoryMock); - - const actualClient = clientProvider.getClient(request, { - excludedWrappers: ['foo', 'bar'], - }); - - expect(actualClient).toBe(defaultClient); - expect(firstClientWrapperFactoryMock).not.toHaveBeenCalled(); - expect(secondClientWrapperFactoryMock).not.toHaveBeenCalled(); }); test(`allows hidden typed to be included`, () => { const defaultClient = Symbol(); const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient); - const clientProvider = new SavedObjectsClientProvider({ + const clientProvider = createClientProvider({ defaultClientFactory: defaultClientFactoryMock, typeRegistry: typeRegistryMock.create(), }); @@ -182,6 +224,7 @@ test(`allows hidden typed to be included`, () => { expect(actualClient).toBe(defaultClient); expect(defaultClientFactoryMock).toHaveBeenCalledWith({ + extensions: expect.any(Object), request, includedHiddenTypes: ['task'], }); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/scoped_client_provider.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/scoped_client_provider.ts index 54e690c3370e1..3988df33d218e 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/scoped_client_provider.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/scoped_client_provider.ts @@ -10,11 +10,19 @@ import type { KibanaRequest } from '@kbn/core-http-server'; import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import type { ISavedObjectTypeRegistry, - SavedObjectsClientWrapperFactory, SavedObjectsClientFactory, SavedObjectsClientProviderOptions, + SavedObjectsEncryptionExtensionFactory, + SavedObjectsSecurityExtensionFactory, + SavedObjectsSpacesExtensionFactory, + SavedObjectsExtensions, + SavedObjectsExtensionFactory, +} from '@kbn/core-saved-objects-server'; +import { + ENCRYPTION_EXTENSION_ID, + SECURITY_EXTENSION_ID, + SPACES_EXTENSION_ID, } from '@kbn/core-saved-objects-server'; -import { PriorityCollection } from './priority_collection'; /** * @internal @@ -30,35 +38,31 @@ export type ISavedObjectsClientProvider = Pick< * @internal */ export class SavedObjectsClientProvider { - private readonly _wrapperFactories = new PriorityCollection<{ - id: string; - factory: SavedObjectsClientWrapperFactory; - }>(); private _clientFactory: SavedObjectsClientFactory; private readonly _originalClientFactory: SavedObjectsClientFactory; + private readonly encryptionExtensionFactory?: SavedObjectsEncryptionExtensionFactory; + private readonly securityExtensionFactory?: SavedObjectsSecurityExtensionFactory; + private readonly spacesExtensionFactory?: SavedObjectsSpacesExtensionFactory; private readonly _typeRegistry: ISavedObjectTypeRegistry; constructor({ defaultClientFactory, typeRegistry, + encryptionExtensionFactory, + securityExtensionFactory, + spacesExtensionFactory, }: { defaultClientFactory: SavedObjectsClientFactory; typeRegistry: ISavedObjectTypeRegistry; + encryptionExtensionFactory?: SavedObjectsEncryptionExtensionFactory; + securityExtensionFactory?: SavedObjectsSecurityExtensionFactory; + spacesExtensionFactory?: SavedObjectsSpacesExtensionFactory; }) { this._originalClientFactory = this._clientFactory = defaultClientFactory; this._typeRegistry = typeRegistry; - } - - addClientWrapperFactory( - priority: number, - id: string, - factory: SavedObjectsClientWrapperFactory - ): void { - if (this._wrapperFactories.has((entry) => entry.id === id)) { - throw new Error(`wrapper factory with id ${id} is already defined`); - } - - this._wrapperFactories.add(priority, { id, factory }); + this.encryptionExtensionFactory = encryptionExtensionFactory; + this.securityExtensionFactory = securityExtensionFactory; + this.spacesExtensionFactory = spacesExtensionFactory; } setClientFactory(customClientFactory: SavedObjectsClientFactory) { @@ -71,25 +75,28 @@ export class SavedObjectsClientProvider { getClient( request: KibanaRequest, - { includedHiddenTypes, excludedWrappers = [] }: SavedObjectsClientProviderOptions = {} + { includedHiddenTypes, excludedExtensions = [] }: SavedObjectsClientProviderOptions = {} ): SavedObjectsClientContract { - const client = this._clientFactory({ + return this._clientFactory({ request, includedHiddenTypes, + extensions: this.getExtensions(request, excludedExtensions), }); + } - return this._wrapperFactories - .toPrioritizedArray() - .reduceRight((clientToWrap, { id, factory }) => { - if (excludedWrappers.includes(id)) { - return clientToWrap; - } + getExtensions(request: KibanaRequest, excludedExtensions: string[]): SavedObjectsExtensions { + const createExt = ( + extensionId: string, + extensionFactory?: SavedObjectsExtensionFactory + ): T | undefined => + !excludedExtensions.includes(extensionId) && !!extensionFactory + ? extensionFactory?.({ typeRegistry: this._typeRegistry, request }) + : undefined; - return factory({ - request, - client: clientToWrap, - typeRegistry: this._typeRegistry, - }); - }, client); + return { + encryptionExtension: createExt(ENCRYPTION_EXTENSION_ID, this.encryptionExtensionFactory), + securityExtension: createExt(SECURITY_EXTENSION_ID, this.securityExtensionFactory), + spacesExtension: createExt(SPACES_EXTENSION_ID, this.spacesExtensionFactory), + }; } } diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/search_dsl.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/search_dsl.ts index 381f20069d25a..bcc705da1282d 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/search_dsl.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/search_dsl/search_dsl.ts @@ -13,7 +13,7 @@ import type { SavedObjectsPitParams } from '@kbn/core-saved-objects-api-server'; import type { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server'; import type { IndexMapping } from '@kbn/core-saved-objects-base-server-internal'; import type { SavedObjectTypeIdTuple } from '@kbn/core-saved-objects-common'; -import { getQueryParams, SearchOperator } from './query_params'; +import { getQueryParams, type SearchOperator } from './query_params'; import { getPitParams } from './pit_params'; import { getSortingParams } from './sorting_params'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.test.ts index 6e6c241965056..9c3113d142c68 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.test.ts @@ -25,6 +25,20 @@ import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-inte import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; import type { UpdateObjectsSpacesParams } from './update_objects_spaces'; import { updateObjectsSpaces } from './update_objects_spaces'; +import { AuditAction, type ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server'; +import { + authMap, + checkAuthError, + enforceError, + typeMapsAreEqual, + setsAreEqual, + setupCheckAuthorized, + setupCheckUnauthorized, + setupEnforceFailure, + setupEnforceSuccess, + setupRedactPassthrough, +} from '../test_helpers/repository.test.common'; +import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock'; type SetupParams = Partial< Pick @@ -67,7 +81,10 @@ describe('#updateObjectsSpaces', () => { let client: ReturnType; /** Sets up the type registry, saved objects client, etc. and return the full parameters object to be passed to `updateObjectsSpaces` */ - function setup({ objects = [], spacesToAdd = [], spacesToRemove = [], options }: SetupParams) { + function setup( + { objects = [], spacesToAdd = [], spacesToRemove = [], options }: SetupParams, + securityExtension?: ISavedObjectsSecurityExtension + ) { const registry = typeRegistryMock.create(); registry.isShareable.mockImplementation( (type) => [SHAREABLE_OBJ_TYPE, SHAREABLE_HIDDEN_OBJ_TYPE].includes(type) // NON_SHAREABLE_OBJ_TYPE is excluded @@ -82,6 +99,7 @@ describe('#updateObjectsSpaces', () => { serializer, logger: loggerMock.create(), getIndexForType: (type: string) => `index-for-${type}`, + securityExtension, objects, spacesToAdd, spacesToRemove, @@ -90,14 +108,16 @@ describe('#updateObjectsSpaces', () => { } /** Mocks the saved objects client so it returns the expected results */ - function mockMgetResults(...results: Array<{ found: boolean }>) { + function mockMgetResults( + ...results: Array<{ found: false } | { found: true; namespaces: string[] }> + ) { client.mget.mockResponseOnce({ docs: results.map((x) => x.found ? { _id: 'doesnt-matter', _index: 'doesnt-matter', - _source: { namespaces: [EXISTING_SPACE] }, + _source: { namespaces: x.namespaces }, ...VERSION_PROPS, found: true, } @@ -111,27 +131,8 @@ describe('#updateObjectsSpaces', () => { } /** Mocks the saved objects client so as to test unsupported server responding with 404 */ - function mockMgetResultsNotFound(...results: Array<{ found: boolean }>) { - client.mget.mockResponseOnce( - { - docs: results.map((x) => - x.found - ? { - _id: 'doesnt-matter', - _index: 'doesnt-matter', - _source: { namespaces: [EXISTING_SPACE] }, - ...VERSION_PROPS, - found: true, - } - : { - _id: 'doesnt-matter', - _index: 'doesnt-matter', - found: false, - } - ), - }, - { statusCode: 404, headers: {} } - ); + function mockMgetResultsNotFound() { + client.mget.mockResponseOnce({ docs: [] }, { statusCode: 404, headers: {} }); } /** Asserts that mget is called for the given objects */ @@ -220,7 +221,7 @@ describe('#updateObjectsSpaces', () => { const objects = [{ type: SHAREABLE_OBJ_TYPE, id: 'id-1' }]; const spacesToAdd = ['foo-space']; const params = setup({ objects, spacesToAdd }); - mockMgetResults({ found: true }); + mockMgetResults({ found: true, namespaces: [EXISTING_SPACE] }); client.bulk.mockReturnValueOnce( elasticsearchClientMock.createErrorTransportRequestPromise(new Error('bulk error')) ); @@ -231,39 +232,38 @@ describe('#updateObjectsSpaces', () => { it('returns mix of type errors, mget/bulk cluster errors, and successes', async () => { const obj1 = { type: SHAREABLE_HIDDEN_OBJ_TYPE, id: 'id-1' }; // invalid type (Not Found) const obj2 = { type: NON_SHAREABLE_OBJ_TYPE, id: 'id-2' }; // non-shareable type (Bad Request) - // obj3 below is mocking an example where a SOC wrapper attempted to retrieve it in a pre-flight request but it was not found. - // Since it has 'spaces: []', that indicates it should be skipped for cluster calls and just returned as a Not Found error. - // Realistically this would not be intermingled with other requested objects that do not have 'spaces' arrays, but it's fine for this - // specific test case. - const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [] }; // does not exist (Not Found) - const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' }; // mget error (found but doesn't exist in the current space) - const obj5 = { type: SHAREABLE_OBJ_TYPE, id: 'id-5' }; // mget error (Not Found) - const obj6 = { type: SHAREABLE_OBJ_TYPE, id: 'id-6' }; // bulk error (mocked as BULK_ERROR) - const obj7 = { type: SHAREABLE_OBJ_TYPE, id: 'id-7' }; // success - - const objects = [obj1, obj2, obj3, obj4, obj5, obj6, obj7]; + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' }; // mget error (found but doesn't exist in the current space) + const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' }; // mget error (Not Found) + const obj5 = { type: SHAREABLE_OBJ_TYPE, id: 'id-5' }; // bulk error (mocked as BULK_ERROR) + const obj6 = { type: SHAREABLE_OBJ_TYPE, id: 'id-6' }; // success + + const objects = [obj1, obj2, obj3, obj4, obj5, obj6]; const spacesToAdd = ['foo-space']; const params = setup({ objects, spacesToAdd }); - mockMgetResults({ found: true }, { found: false }, { found: true }, { found: true }); // results for obj4, obj5, obj6, and obj7 - mockRawDocExistsInNamespace.mockReturnValueOnce(false); // for obj4 + mockMgetResults( + { found: true, namespaces: ['another-space'] }, // result for obj3 + { found: false }, // result for obj4 + { found: true, namespaces: [EXISTING_SPACE] }, // result for obj5 + { found: true, namespaces: [EXISTING_SPACE] } // result for obj6 + ); + mockRawDocExistsInNamespace.mockReturnValueOnce(false); // for obj3 + mockRawDocExistsInNamespace.mockReturnValueOnce(true); // for obj5 mockRawDocExistsInNamespace.mockReturnValueOnce(true); // for obj6 - mockRawDocExistsInNamespace.mockReturnValueOnce(true); // for obj7 - mockBulkResults({ error: true }, { error: false }); // results for obj6 and obj7 + mockBulkResults({ error: true }, { error: false }); // results for obj5 and obj6 const result = await updateObjectsSpaces(params); expect(client.mget).toHaveBeenCalledTimes(1); - expectMgetArgs(obj4, obj5, obj6, obj7); + expectMgetArgs(obj3, obj4, obj5, obj6); expect(mockRawDocExistsInNamespace).toHaveBeenCalledTimes(3); expect(client.bulk).toHaveBeenCalledTimes(1); - expectBulkArgs({ action: 'update', object: obj6 }, { action: 'update', object: obj7 }); + expectBulkArgs({ action: 'update', object: obj5 }, { action: 'update', object: obj6 }); expect(result.objects).toEqual([ { ...obj1, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) }, { ...obj2, spaces: [], error: expect.objectContaining({ error: 'Bad Request' }) }, { ...obj3, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) }, { ...obj4, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) }, - { ...obj5, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) }, - { ...obj6, spaces: [], error: BULK_ERROR }, - { ...obj7, spaces: [EXISTING_SPACE, 'foo-space'] }, + { ...obj5, spaces: [], error: BULK_ERROR }, + { ...obj6, spaces: [EXISTING_SPACE, 'foo-space'] }, ]); }); @@ -271,7 +271,7 @@ describe('#updateObjectsSpaces', () => { const objects = [{ type: SHAREABLE_OBJ_TYPE, id: 'id-1' }]; const spacesToAdd = ['foo-space']; const params = setup({ objects, spacesToAdd }); - mockMgetResultsNotFound({ found: true }); + mockMgetResultsNotFound(); await expect(() => updateObjectsSpaces(params)).rejects.toThrowError( SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError() @@ -281,71 +281,62 @@ describe('#updateObjectsSpaces', () => { // Note: these test cases do not include requested objects that will result in errors (those are covered above) describe('cluster and module calls', () => { - it('mget call skips objects that have "spaces" defined', async () => { - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [EXISTING_SPACE] }; // will not be retrieved - const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; // will be passed to mget - - const objects = [obj1, obj2]; - const spacesToAdd = ['foo-space']; - const params = setup({ objects, spacesToAdd }); - mockMgetResults({ found: true }); // result for obj2 - mockBulkResults({ error: false }, { error: false }); // results for obj1 and obj2 - - await updateObjectsSpaces(params); - expect(client.mget).toHaveBeenCalledTimes(1); - expectMgetArgs(obj2); - }); - - it('does not call mget if all objects have "spaces" defined', async () => { - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [EXISTING_SPACE] }; // will not be retrieved - + it('makes mget call for objects', async () => { + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; const objects = [obj1]; const spacesToAdd = ['foo-space']; const params = setup({ objects, spacesToAdd }); + mockMgetResults({ found: true, namespaces: [EXISTING_SPACE] }); // result for obj1 mockBulkResults({ error: false }); // result for obj1 await updateObjectsSpaces(params); - expect(client.mget).not.toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalledTimes(1); + expectMgetArgs(obj1); }); describe('bulk call skips objects that will not be changed', () => { it('when adding spaces', async () => { - const space1 = 'space-to-add'; - const space2 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed - const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space2] }; // will be updated + const otherSpace = 'space-to-add'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; const objects = [obj1, obj2]; - const spacesToAdd = [space1]; + const spacesToAdd = [otherSpace]; const params = setup({ objects, spacesToAdd }); - // this test case does not call mget + mockMgetResults( + { found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj1 -- will not be changed + { found: true, namespaces: [EXISTING_SPACE] } // result for obj2 -- will be updated to add otherSpace + ); mockBulkResults({ error: false }); // result for obj2 await updateObjectsSpaces(params); expect(client.bulk).toHaveBeenCalledTimes(1); expectBulkArgs({ action: 'update', - object: { ...obj2, namespaces: [space2, space1] }, + object: { ...obj2, namespaces: [EXISTING_SPACE, otherSpace] }, }); }); it('when removing spaces', async () => { - const space1 = 'space-to-remove'; - const space2 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will not be changed - const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space1, space2] }; // will be updated to remove space1 - const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1] }; // will be deleted (since it would have no spaces left) + const otherSpace = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' }; const objects = [obj1, obj2, obj3]; - const spacesToRemove = [space1]; + const spacesToRemove = [EXISTING_SPACE]; const params = setup({ objects, spacesToRemove }); - // this test case does not call mget + mockMgetResults( + { found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj1 -- will not be changed + { found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj2 -- will be updated to remove EXISTING_SPACE + { found: true, namespaces: [EXISTING_SPACE] } // result for obj3 -- will be deleted (since it would have no spaces left) + ); mockBulkResults({ error: false }, { error: false }); // results for obj2 and obj3 await updateObjectsSpaces(params); expect(client.bulk).toHaveBeenCalledTimes(1); expectBulkArgs( - { action: 'update', object: { ...obj2, namespaces: [space2] } }, + { action: 'update', object: { ...obj2, namespaces: [otherSpace] } }, { action: 'delete', object: obj3 } ); }); @@ -353,52 +344,61 @@ describe('#updateObjectsSpaces', () => { it('when adding and removing spaces', async () => { const space1 = 'space-to-add'; const space2 = 'space-to-remove'; - const space3 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed - const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space3] }; // will be updated to add space1 - const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1, space2] }; // will be updated to remove space2 - const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4', spaces: [space2, space3] }; // will be updated to add space1 and remove space2 + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' }; + const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' }; const objects = [obj1, obj2, obj3, obj4]; const spacesToAdd = [space1]; const spacesToRemove = [space2]; const params = setup({ objects, spacesToAdd, spacesToRemove }); - // this test case does not call mget + mockMgetResults( + { found: true, namespaces: [EXISTING_SPACE, space1] }, // result for obj1 -- will not be changed + { found: true, namespaces: [EXISTING_SPACE] }, // result for obj2 -- will be updated to add space1 + { found: true, namespaces: [EXISTING_SPACE, space1, space2] }, // result for obj3 -- will be updated to remove space2 + { found: true, namespaces: [EXISTING_SPACE, space2] } // result for obj3 -- will be updated to add space1 and remove space2 + ); mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4 await updateObjectsSpaces(params); expect(client.bulk).toHaveBeenCalledTimes(1); expectBulkArgs( - { action: 'update', object: { ...obj2, namespaces: [space3, space1] } }, - { action: 'update', object: { ...obj3, namespaces: [space1] } }, - { action: 'update', object: { ...obj4, namespaces: [space3, space1] } } + { action: 'update', object: { ...obj2, namespaces: [EXISTING_SPACE, space1] } }, + { action: 'update', object: { ...obj3, namespaces: [EXISTING_SPACE, space1] } }, + { action: 'update', object: { ...obj4, namespaces: [EXISTING_SPACE, space1] } } ); }); }); describe('does not call bulk if all objects do not need to be changed', () => { it('when adding spaces', async () => { - const space = 'space-to-add'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space] }; // will not be changed + const otherSpace = 'space-to-add'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; const objects = [obj1]; - const spacesToAdd = [space]; + const spacesToAdd = [otherSpace]; const params = setup({ objects, spacesToAdd }); - // this test case does not call mget or bulk + mockMgetResults( + { found: true, namespaces: [EXISTING_SPACE, otherSpace] } // result for obj1 -- will not be changed + ); + // this test case does not call bulk await updateObjectsSpaces(params); expect(client.bulk).not.toHaveBeenCalled(); }); it('when removing spaces', async () => { - const space1 = 'space-to-remove'; - const space2 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will not be changed + const otherSpace = 'space-to-remove'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; const objects = [obj1]; - const spacesToRemove = [space1]; + const spacesToRemove = [otherSpace]; const params = setup({ objects, spacesToRemove }); - // this test case does not call mget or bulk + mockMgetResults( + { found: true, namespaces: [EXISTING_SPACE] } // result for obj1 -- will not be changed + ); + // this test case does not call bulk await updateObjectsSpaces(params); expect(client.bulk).not.toHaveBeenCalled(); @@ -407,13 +407,16 @@ describe('#updateObjectsSpaces', () => { it('when adding and removing spaces', async () => { const space1 = 'space-to-add'; const space2 = 'space-to-remove'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; const objects = [obj1]; const spacesToAdd = [space1]; const spacesToRemove = [space2]; const params = setup({ objects, spacesToAdd, spacesToRemove }); - // this test case does not call mget or bulk + mockMgetResults( + { found: true, namespaces: [EXISTING_SPACE, space1] } // result for obj1 -- will not be changed + ); + // this test case does not call bulk await updateObjectsSpaces(params); expect(client.bulk).not.toHaveBeenCalled(); @@ -424,33 +427,39 @@ describe('#updateObjectsSpaces', () => { it('does not delete aliases for objects that were not removed from any spaces', async () => { const space1 = 'space-to-add'; const space2 = 'space-to-remove'; - const space3 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed - const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space3] }; // will be updated + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; const objects = [obj1, obj2]; const spacesToAdd = [space1]; const spacesToRemove = [space2]; const params = setup({ objects, spacesToAdd, spacesToRemove }); - // this test case does not call mget + mockMgetResults( + { found: true, namespaces: [EXISTING_SPACE, space1] }, // result for obj1 -- will not be changed + { found: true, namespaces: [EXISTING_SPACE] } // result for obj2 -- will be updated to add space1 + ); mockBulkResults({ error: false }); // result for obj2 await updateObjectsSpaces(params); expect(client.bulk).toHaveBeenCalledTimes(1); - expectBulkArgs({ action: 'update', object: { ...obj2, namespaces: [space3, space1] } }); + expectBulkArgs({ + action: 'update', + object: { ...obj2, namespaces: [EXISTING_SPACE, space1] }, + }); expect(mockDeleteLegacyUrlAliases).not.toHaveBeenCalled(); expect(params.logger.error).not.toHaveBeenCalled(); }); it('does not delete aliases for objects that were removed from spaces but were also added to All Spaces (*)', async () => { - const space2 = 'space-to-remove'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; const objects = [obj1]; const spacesToAdd = [ALL_NAMESPACES_STRING]; - const spacesToRemove = [space2]; + const spacesToRemove = [EXISTING_SPACE]; const params = setup({ objects, spacesToAdd, spacesToRemove }); - // this test case does not call mget + mockMgetResults( + { found: true, namespaces: [EXISTING_SPACE] } // result for obj1 -- will be updated to remove EXISTING_SPACE and add * + ); mockBulkResults({ error: false }); // result for obj1 await updateObjectsSpaces(params); @@ -463,33 +472,36 @@ describe('#updateObjectsSpaces', () => { expect(params.logger.error).not.toHaveBeenCalled(); }); - it('deletes aliases for objects that were removed from specific spaces using "deleteBehavior: exclusive"', async () => { + it('deletes aliases for objects that were removed from specific spaces using "deleteBehavior: inclusive"', async () => { const space1 = 'space-to-remove'; - const space2 = 'another-space-to-remove'; - const space3 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space3] }; // will not be changed - const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1, space2, space3] }; // will be updated - const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will be deleted + const space2 = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' }; const objects = [obj1, obj2, obj3]; - const spacesToRemove = [space1, space2]; + const spacesToRemove = [EXISTING_SPACE, space1]; const params = setup({ objects, spacesToRemove }); - // this test case does not call mget + mockMgetResults( + { found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj1 -- will not be changed + { found: true, namespaces: [EXISTING_SPACE, space1, space2] }, // result for obj2 -- will be updated to remove EXISTING_SPACE and space1 + { found: true, namespaces: [EXISTING_SPACE] } // result for obj3 -- will be deleted + ); mockBulkResults({ error: false }, { error: false }); // result2 for obj2 and obj3 await updateObjectsSpaces(params); expect(client.bulk).toHaveBeenCalledTimes(1); expectBulkArgs( - { action: 'update', object: { ...obj2, namespaces: [space3] } }, + { action: 'update', object: { ...obj2, namespaces: [space2] } }, { action: 'delete', object: obj3 } ); expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledTimes(2); expect(mockDeleteLegacyUrlAliases).toHaveBeenNthCalledWith( - 1, // the first call resulted in an error which generated a log message (see assertion below) + 1, expect.objectContaining({ type: obj2.type, id: obj2.id, - namespaces: [space1, space2], + namespaces: [EXISTING_SPACE, space1], deleteBehavior: 'inclusive', }) ); @@ -498,42 +510,40 @@ describe('#updateObjectsSpaces', () => { expect.objectContaining({ type: obj3.type, id: obj3.id, - namespaces: [space1], + namespaces: [EXISTING_SPACE], deleteBehavior: 'inclusive', }) ); expect(params.logger.error).not.toHaveBeenCalled(); }); - it('deletes aliases for objects that were removed from all spaces using "deleteBehavior: inclusive"', async () => { - const space1 = 'space-to-add'; - const space2 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will be updated to add space1 - const obj2 = { - type: SHAREABLE_OBJ_TYPE, - id: 'id-2', - spaces: [space2, ALL_NAMESPACES_STRING], // will be updated to add space1 and remove * - }; + it('deletes aliases for objects that were removed from all spaces using "deleteBehavior: exclusive"', async () => { + const otherSpace = 'space-to-add'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; const objects = [obj1, obj2]; - const spacesToAdd = [space1]; + const spacesToAdd = [EXISTING_SPACE, otherSpace]; const spacesToRemove = [ALL_NAMESPACES_STRING]; const params = setup({ objects, spacesToAdd, spacesToRemove }); - // this test case does not call mget - mockBulkResults({ error: false }); // result for obj1 + mockMgetResults( + { found: true, namespaces: [EXISTING_SPACE] }, // result for obj1 -- will be updated to add otherSpace + { found: true, namespaces: [ALL_NAMESPACES_STRING] } // result for obj2 -- will be updated to remove * and add EXISTING_SPACE and otherSpace + ); + mockBulkResults({ error: false }, { error: false }); // result for obj1 and obj2 await updateObjectsSpaces(params); expect(client.bulk).toHaveBeenCalledTimes(1); expectBulkArgs( - { action: 'update', object: { ...obj1, namespaces: [space2, space1] } }, - { action: 'update', object: { ...obj2, namespaces: [space2, space1] } } + { action: 'update', object: { ...obj1, namespaces: [EXISTING_SPACE, otherSpace] } }, + { action: 'update', object: { ...obj2, namespaces: [EXISTING_SPACE, otherSpace] } } ); expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledTimes(1); expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith( expect.objectContaining({ type: obj2.type, id: obj2.id, - namespaces: [space2, space1], + namespaces: [EXISTING_SPACE, otherSpace], deleteBehavior: 'exclusive', }) ); @@ -541,20 +551,21 @@ describe('#updateObjectsSpaces', () => { }); it('logs a message when deleteLegacyUrlAliases returns an error', async () => { - const space1 = 'space-to-remove'; - const space2 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1, space2] }; // will be updated + const otherSpace = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; const objects = [obj1]; - const spacesToRemove = [space1]; + const spacesToRemove = [otherSpace]; const params = setup({ objects, spacesToRemove }); - // this test case does not call mget + mockMgetResults( + { found: true, namespaces: [EXISTING_SPACE, otherSpace] } // result for obj1 -- will be updated to remove otherSpace + ); mockBulkResults({ error: false }); // result for obj1 mockDeleteLegacyUrlAliases.mockRejectedValueOnce(new Error('Oh no!')); // result for deleting aliases for obj1 await updateObjectsSpaces(params); expect(client.bulk).toHaveBeenCalledTimes(1); - expectBulkArgs({ action: 'update', object: { ...obj1, namespaces: [space2] } }); + expectBulkArgs({ action: 'update', object: { ...obj1, namespaces: [EXISTING_SPACE] } }); expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledTimes(1); // don't assert deleteLegacyUrlAliases args, we have tests for that above expect(params.logger.error).toHaveBeenCalledTimes(1); expect(params.logger.error).toHaveBeenCalledWith( @@ -566,68 +577,323 @@ describe('#updateObjectsSpaces', () => { describe('returns expected results', () => { it('when adding spaces', async () => { - const space1 = 'space-to-add'; - const space2 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed - const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space2] }; // will be updated + const otherSpace = 'space-to-add'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; const objects = [obj1, obj2]; - const spacesToAdd = [space1]; + const spacesToAdd = [otherSpace]; const params = setup({ objects, spacesToAdd }); - // this test case does not call mget + mockMgetResults( + { found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj1 -- will not be changed + { found: true, namespaces: [EXISTING_SPACE] } // result for obj2 -- will be updated to add otherSpace + ); mockBulkResults({ error: false }); // result for obj2 const result = await updateObjectsSpaces(params); expect(result.objects).toEqual([ - { ...obj1, spaces: [space1] }, - { ...obj2, spaces: [space2, space1] }, + { ...obj1, spaces: [EXISTING_SPACE, otherSpace] }, + { ...obj2, spaces: [EXISTING_SPACE, otherSpace] }, ]); }); it('when removing spaces', async () => { - const space1 = 'space-to-remove'; - const space2 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will not be changed - const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space1, space2] }; // will be updated to remove space1 - const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1] }; // will be deleted (since it would have no spaces left) + const otherSpace = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' }; const objects = [obj1, obj2, obj3]; - const spacesToRemove = [space1]; + const spacesToRemove = [EXISTING_SPACE]; const params = setup({ objects, spacesToRemove }); - // this test case does not call mget + mockMgetResults( + { found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj1 -- will not be changed + { found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj2 -- will be updated to remove EXISTING_SPACE + { found: true, namespaces: [EXISTING_SPACE] } // result for obj3 -- will be deleted (since it would have no spaces left) + ); mockBulkResults({ error: false }, { error: false }); // results for obj2 and obj3 const result = await updateObjectsSpaces(params); expect(result.objects).toEqual([ - { ...obj1, spaces: [space2] }, - { ...obj2, spaces: [space2] }, + { ...obj1, spaces: [ALL_NAMESPACES_STRING] }, + { ...obj2, spaces: [otherSpace] }, { ...obj3, spaces: [] }, ]); }); it('when adding and removing spaces', async () => { - const space1 = 'space-to-add'; - const space2 = 'space-to-remove'; - const space3 = 'other-space'; - const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed - const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space3] }; // will be updated to add space1 - const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1, space2] }; // will be updated to remove space2 - const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4', spaces: [space2, space3] }; // will be updated to add space1 and remove space2 + const otherSpace = 'space-to-add'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' }; + const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' }; const objects = [obj1, obj2, obj3, obj4]; - const spacesToAdd = [space1]; - const spacesToRemove = [space2]; + const spacesToAdd = [otherSpace]; + const spacesToRemove = [EXISTING_SPACE]; const params = setup({ objects, spacesToAdd, spacesToRemove }); - // this test case does not call mget + mockMgetResults( + { found: true, namespaces: [ALL_NAMESPACES_STRING, otherSpace] }, // result for obj1 -- will not be changed + { found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj2 -- will be updated to add otherSpace + { found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj3 -- will be updated to remove EXISTING_SPACE + { found: true, namespaces: [EXISTING_SPACE] } // result for obj4 -- will be updated to remove EXISTING_SPACE and add otherSpace + ); mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4 const result = await updateObjectsSpaces(params); expect(result.objects).toEqual([ - { ...obj1, spaces: [space1] }, - { ...obj2, spaces: [space3, space1] }, - { ...obj3, spaces: [space1] }, - { ...obj4, spaces: [space3, space1] }, + { ...obj1, spaces: [ALL_NAMESPACES_STRING, otherSpace] }, + { ...obj2, spaces: [ALL_NAMESPACES_STRING, otherSpace] }, + { ...obj3, spaces: [otherSpace] }, + { ...obj4, spaces: [otherSpace] }, ]); }); }); + + describe(`with security extension`, () => { + let mockSecurityExt: jest.Mocked; + let params: UpdateObjectsSpacesParams; + + describe(`errors`, () => { + beforeEach(() => { + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const objects = [obj1]; + const spacesToAdd = ['foo-space']; + mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension(); + params = setup({ objects, spacesToAdd }, mockSecurityExt); + mockMgetResults({ found: true, namespaces: [EXISTING_SPACE] }); // result for obj1 + mockBulkResults({ error: false }); // result for obj1 + }); + + test(`propagates error from es client bulk get`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + const error = SavedObjectsErrorHelpers.createBadRequestError('OOPS!'); + + mockGetBulkOperationError.mockReset(); + client.bulk.mockReset(); + client.bulk.mockImplementationOnce(() => { + throw error; + }); + + await expect(updateObjectsSpaces(params)).rejects.toThrow(error); + }); + + test(`propagates decorated error when checkAuthorization rejects promise`, async () => { + mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError); + + await expect(updateObjectsSpaces(params)).rejects.toThrow(checkAuthError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled(); + }); + + test(`propagates decorated error when unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect(updateObjectsSpaces(params)).rejects.toThrow(enforceError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + }); + + test(`adds audit event when not unauthorized`, async () => { + setupCheckUnauthorized(mockSecurityExt); + setupEnforceFailure(mockSecurityExt); + + await expect(updateObjectsSpaces(params)).rejects.toThrow(enforceError); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.UPDATE_OBJECTS_SPACES, + addToSpaces: params.spacesToAdd, + deleteFromSpaces: undefined, + savedObject: { type: params.objects[0].type, id: params.objects[0].id }, + error: enforceError, + }); + }); + + test(`returns error from es client bulk operation`, async () => { + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + + mockGetBulkOperationError.mockReset(); + client.bulk.mockReset(); + mockBulkResults({ error: true }); + + const result = await updateObjectsSpaces(params); + expect(result).toEqual({ + objects: [ + { + error: BULK_ERROR, + id: params.objects[0].id, + spaces: [], + type: params.objects[0].type, + }, + ], + }); + }); + }); + + describe('success', () => { + const defaultSpace = 'default'; + const otherSpace = 'space-to-add'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' }; + const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' }; + + const objects = [obj1, obj2, obj3, obj4]; + const spacesToAdd = [otherSpace]; + const spacesToRemove = [EXISTING_SPACE]; + + beforeEach(() => { + mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension(); + params = setup({ objects, spacesToAdd, spacesToRemove }, mockSecurityExt); + mockMgetResults( + { found: true, namespaces: [ALL_NAMESPACES_STRING, otherSpace] }, // result for obj1 -- will not be changed + { found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj2 -- will be updated to add otherSpace + { found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj3 -- will be updated to remove EXISTING_SPACE + { found: true, namespaces: [EXISTING_SPACE] } // result for obj4 -- will be updated to remove EXISTING_SPACE and add otherSpace + ); + mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4 + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + }); + + test(`calls checkAuthorization with type, actions, and namespaces`, async () => { + await updateObjectsSpaces(params); + + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['share_to_space']); + const expectedSpaces = new Set([defaultSpace, otherSpace, EXISTING_SPACE]); + const expectedTypes = new Set([SHAREABLE_OBJ_TYPE]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls enforceAuthorization with action, type map, and auth map`, async () => { + await updateObjectsSpaces(params); + + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'share_to_space', + }) + ); + const expectedTypesAndSpaces = new Map([ + [SHAREABLE_OBJ_TYPE, new Set([defaultSpace, EXISTING_SPACE, otherSpace])], + ]); + + const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } = + mockSecurityExt.enforceAuthorization.mock.calls[0][0]; + + expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy(); + expect(actualTypeMap).toBe(authMap); + }); + + test(`adds audit event per object when successful`, async () => { + await updateObjectsSpaces(params); + + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length); + objects.forEach((obj) => { + expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.UPDATE_OBJECTS_SPACES, + savedObject: { type: obj.type, id: obj.id }, + outcome: 'unknown', + addToSpaces: spacesToAdd, + deleteFromSpaces: spacesToRemove, + error: undefined, + }); + }); + }); + }); + + describe('all spaces', () => { + const defaultSpace = 'default'; + const otherSpace = 'space-to-add'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' }; + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' }; + const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' }; + const objects = [obj1, obj2, obj3, obj4]; + + const setupForAllSpaces = (spacesToAdd: string[], spacesToRemove: string[]) => { + mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension(); + params = setup({ objects, spacesToAdd, spacesToRemove }, mockSecurityExt); + mockMgetResults( + { found: true, namespaces: [ALL_NAMESPACES_STRING, otherSpace] }, // result for obj1 -- will not be changed + { found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj2 -- will be updated to add otherSpace + { found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj3 -- will be updated to remove EXISTING_SPACE + { found: true, namespaces: [EXISTING_SPACE] } // result for obj4 -- will be updated to remove EXISTING_SPACE and add otherSpace + ); + mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4 + setupCheckAuthorized(mockSecurityExt); + setupEnforceSuccess(mockSecurityExt); + setupRedactPassthrough(mockSecurityExt); + }; + + test(`calls checkAuthorization with '*' when spacesToAdd includes '*'`, async () => { + const spacesToAdd = ['*']; + const spacesToRemove = [otherSpace]; + setupForAllSpaces(spacesToAdd, spacesToRemove); + await updateObjectsSpaces(params); + + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['share_to_space']); + const expectedSpaces = new Set(['*', defaultSpace, otherSpace, EXISTING_SPACE]); + const expectedTypes = new Set([SHAREABLE_OBJ_TYPE]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + + test(`calls checkAuthorization with '*' when spacesToRemove includes '*'`, async () => { + const spacesToAdd = [otherSpace]; + const spacesToRemove = ['*']; + setupForAllSpaces(spacesToAdd, spacesToRemove); + await updateObjectsSpaces(params); + + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1); + const expectedActions = new Set(['share_to_space']); + const expectedSpaces = new Set(['*', defaultSpace, otherSpace, EXISTING_SPACE]); + const expectedTypes = new Set([SHAREABLE_OBJ_TYPE]); + + const { + actions: actualActions, + spaces: actualSpaces, + types: actualTypes, + } = mockSecurityExt.checkAuthorization.mock.calls[0][0]; + + expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy(); + expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy(); + expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy(); + }); + }); + }); }); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.ts index 4053fcff4a8ea..f4afe7cf96961 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.ts @@ -18,24 +18,28 @@ import type { SavedObjectsUpdateObjectsSpacesResponse, SavedObjectsUpdateObjectsSpacesResponseObject, } from '@kbn/core-saved-objects-api-server'; -import type { - ISavedObjectTypeRegistry, - SavedObjectsRawDocSource, +import { + AuditAction, + type ISavedObjectsSecurityExtension, + type ISavedObjectTypeRegistry, + type SavedObjectsRawDocSource, } from '@kbn/core-saved-objects-server'; import { SavedObjectsErrorHelpers, ALL_NAMESPACES_STRING, type DecoratedError, + SavedObjectsUtils, } from '@kbn/core-saved-objects-utils-server'; import type { IndexMapping, SavedObjectsSerializer, } from '@kbn/core-saved-objects-base-server-internal'; +import type { SavedObject } from '@kbn/core-saved-objects-common'; import { getBulkOperationError, getExpectedVersionProperties, rawDocExistsInNamespace, - Either, + type Either, isLeft, isRight, } from './internal_utils'; @@ -57,6 +61,7 @@ export interface UpdateObjectsSpacesParams { serializer: SavedObjectsSerializer; logger: Logger; getIndexForType: (type: string) => string; + securityExtension: ISavedObjectsSecurityExtension | undefined; objects: SavedObjectsUpdateObjectsSpacesObject[]; spacesToAdd: string[]; spacesToRemove: string[]; @@ -86,6 +91,7 @@ export async function updateObjectsSpaces({ serializer, logger, getIndexForType, + securityExtension, objects, spacesToAdd, spacesToRemove, @@ -106,9 +112,12 @@ export async function updateObjectsSpaces({ let bulkGetRequestIndexCounter = 0; const expectedBulkGetResults: Array< - Either> + Either< + SavedObjectsUpdateObjectsSpacesResponseObject, + { type: string; id: string; version: string | undefined; esRequestIndex: number } + > > = objects.map((object) => { - const { type, id, spaces, version } = object; + const { type, id, version } = object; if (!allowedTypes.includes(type)) { const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); @@ -134,23 +143,26 @@ export async function updateObjectsSpaces({ value: { type, id, - spaces, version, - ...(!spaces && { esRequestIndex: bulkGetRequestIndexCounter++ }), + esRequestIndex: bulkGetRequestIndexCounter++, }, }; }); - const bulkGetDocs = expectedBulkGetResults.reduce((acc, x) => { - if (isRight(x) && x.value.esRequestIndex !== undefined) { - acc.push({ - _id: serializer.generateRawId(undefined, x.value.type, x.value.id), - _index: getIndexForType(x.value.type), - _source: ['type', 'namespaces'], - }); - } - return acc; - }, []); + const validObjects = expectedBulkGetResults.filter(isRight); + if (validObjects.length === 0) { + // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. + return { + // Filter with the `isLeft` comparator simply because it's a convenient type guard, we know the only expected results are errors. + objects: expectedBulkGetResults.filter(isLeft).map(({ value }) => value), + }; + } + + const bulkGetDocs = validObjects.map((x) => ({ + _id: serializer.generateRawId(undefined, x.value.type, x.value.id), + _index: getIndexForType(x.value.type), + _source: ['type', 'namespaces'], + })); const bulkGetResponse = bulkGetDocs.length ? await client.mget( { body: { docs: bulkGetDocs } }, @@ -167,6 +179,59 @@ export async function updateObjectsSpaces({ ) { throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); } + + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + const addToSpaces = spacesToAdd.length ? spacesToAdd : undefined; + const deleteFromSpaces = spacesToRemove.length ? spacesToRemove : undefined; + const typesAndSpaces = new Map>(); + const spacesToAuthorize = new Set(); + for (const { value } of validObjects) { + const { type, esRequestIndex: index } = value; + const preflightResult = index !== undefined ? bulkGetResponse?.body.docs[index] : undefined; + + const spacesToEnforce = + typesAndSpaces.get(type) ?? new Set([...spacesToAdd, ...spacesToRemove, namespaceString]); // Always enforce authZ for the active space + typesAndSpaces.set(type, spacesToEnforce); + for (const space of spacesToEnforce) { + spacesToAuthorize.add(space); + } + // @ts-expect-error MultiGetHit._source is optional + for (const space of preflightResult?._source?.namespaces ?? []) { + // Existing namespaces are included so we can later redact if necessary + // If this is a specific space, add it to the spaces we'll check privileges for (don't accidentally check for global privileges) + if (space === ALL_NAMESPACES_STRING) continue; + spacesToAuthorize.add(space); + } + } + + const authorizationResult = await securityExtension?.checkAuthorization({ + types: new Set(typesAndSpaces.keys()), + spaces: spacesToAuthorize, + actions: new Set(['share_to_space']), + // If a user tries to share/unshare an object to/from '*', they need to have 'share_to_space' privileges for the Global Resource (e.g., + // All privileges for All Spaces). + options: { allowGlobalResource: true }, + }); + if (authorizationResult) { + securityExtension!.enforceAuthorization({ + typesAndSpaces, + action: 'share_to_space', + typeMap: authorizationResult.typeMap, + auditCallback: (error) => { + for (const { value } of validObjects) { + securityExtension!.addAuditEvent({ + action: AuditAction.UPDATE_OBJECTS_SPACES, + savedObject: { type: value.type, id: value.id }, + addToSpaces, + deleteFromSpaces, + error, + ...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update operation has not occurred yet + }); + } + }, + }); + } + const time = new Date().toISOString(); let bulkOperationRequestIndexCounter = 0; const bulkOperationParams: estypes.BulkOperationContainer[] = []; @@ -181,40 +246,25 @@ export async function updateObjectsSpaces({ return expectedBulkGetResult; } - const { id, type, spaces, version, esRequestIndex } = expectedBulkGetResult.value; - - let currentSpaces: string[] = spaces; - let versionProperties; - if (esRequestIndex !== undefined) { - const doc = bulkGetResponse?.body.docs[esRequestIndex]; - const isErrorDoc = isMgetError(doc); - - if ( - isErrorDoc || - !doc?.found || - // @ts-expect-error MultiGetHit._source is optional - !rawDocExistsInNamespace(registry, doc, namespace) - ) { - const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); - return { - tag: 'Left', - value: { id, type, spaces: [], error }, - }; - } - currentSpaces = doc._source?.namespaces ?? []; + const { id, type, version, esRequestIndex } = expectedBulkGetResult.value; + const doc = bulkGetResponse!.body.docs[esRequestIndex]; + if ( + isMgetError(doc) || + !doc?.found || // @ts-expect-error MultiGetHit._source is optional - versionProperties = getExpectedVersionProperties(version, doc); - } else if (spaces?.length === 0) { - // A SOC wrapper attempted to retrieve this object in a pre-flight request and it was not found. + !rawDocExistsInNamespace(registry, doc, namespace) + ) { const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); return { tag: 'Left', value: { id, type, spaces: [], error }, }; - } else { - versionProperties = getExpectedVersionProperties(version); } + const currentSpaces = doc._source?.namespaces ?? []; + // @ts-expect-error MultiGetHit._source is optional + const versionProperties = getExpectedVersionProperties(version, doc); + const { updatedSpaces, removedSpaces, isUpdateRequired } = analyzeSpaceChanges( currentSpaces, spacesToAdd, @@ -296,6 +346,13 @@ export async function updateObjectsSpaces({ } } + if (authorizationResult) { + const { namespaces: redactedSpaces } = securityExtension!.redactNamespaces({ + savedObject: { type, namespaces: updatedSpaces } as SavedObject, // Other SavedObject attributes aren't required + typeMap: authorizationResult.typeMap, + }); + return { id, type, spaces: redactedSpaces! }; + } return { id, type, spaces: updatedSpaces }; } ), diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/point_in_time_finder.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/point_in_time_finder.mock.ts index d6b7e51f78bd1..a9365975da1e6 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/point_in_time_finder.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/point_in_time_finder.mock.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; import type { SavedObjectsClientContract, ISavedObjectsRepository, @@ -48,7 +48,7 @@ const createPointInTimeFinderMock = ({ const createPointInTimeFinderClientMock = (): jest.Mocked => { return { find: jest.fn(), - openPointInTimeForType: jest.fn(), + openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), closePointInTime: jest.fn(), }; }; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/saved_objects_extensions.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/saved_objects_extensions.mock.ts new file mode 100644 index 0000000000000..f4308ee6254c7 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/saved_objects_extensions.mock.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ISavedObjectsEncryptionExtension, + ISavedObjectsSecurityExtension, + ISavedObjectsSpacesExtension, + SavedObjectsExtensions, +} from '@kbn/core-saved-objects-server'; + +const createEncryptionExtension = (): jest.Mocked => ({ + isEncryptableType: jest.fn(), + decryptOrStripResponseAttributes: jest.fn(), + encryptAttributes: jest.fn(), +}); + +const createSecurityExtension = (): jest.Mocked => ({ + checkAuthorization: jest.fn(), + enforceAuthorization: jest.fn(), + addAuditEvent: jest.fn(), + redactNamespaces: jest.fn(), +}); + +const createSpacesExtension = (): jest.Mocked => ({ + getCurrentNamespace: jest.fn(), + getSearchableNamespaces: jest.fn(), +}); + +const create = (): jest.Mocked => ({ + encryptionExtension: createEncryptionExtension(), + securityExtension: createSecurityExtension(), + spacesExtension: createSpacesExtension(), +}); + +export const savedObjectsExtensionsMock = { + create, + createEncryptionExtension, + createSecurityExtension, + createSpacesExtension, +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts new file mode 100644 index 0000000000000..600b1e967c677 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts @@ -0,0 +1,930 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { schema } from '@kbn/config-schema'; +import { loggerMock } from '@kbn/logging-mocks'; +import { isEqual } from 'lodash'; +import { Payload } from 'elastic-apm-node'; +import { + AuthorizationTypeEntry, + EnforceAuthorizationParams, + ISavedObjectsSecurityExtension, + SavedObjectsMappingProperties, + SavedObjectsRawDocSource, + SavedObjectsType, + SavedObjectsTypeMappingDefinition, +} from '@kbn/core-saved-objects-server'; +import { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-common'; +import { + SavedObjectsBaseOptions, + SavedObjectsBulkCreateObject, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkGetObject, + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateOptions, + SavedObjectsCreateOptions, + SavedObjectsDeleteOptions, + SavedObjectsFindOptions, + SavedObjectsUpdateOptions, +} from '@kbn/core-saved-objects-api-server'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server'; + +import { + encodeHitVersion, + SavedObjectsSerializer, + SavedObjectTypeRegistry, +} from '@kbn/core-saved-objects-base-server-internal'; +import { + elasticsearchClientMock, + ElasticsearchClientMock, +} from '@kbn/core-elasticsearch-client-server-mocks'; +import { DocumentMigrator } from '@kbn/core-saved-objects-migration-server-internal'; +import { mockGetSearchDsl } from '../lib/repository.test.mock'; +import { SavedObjectsRepository } from '../lib/repository'; + +export const DEFAULT_SPACE = 'default'; + +export interface ExpectedErrorResult { + type: string; + id: string; + error: Record; +} + +export type ErrorPayload = Error & Payload; + +export const createBadRequestErrorPayload = (reason?: string) => + SavedObjectsErrorHelpers.createBadRequestError(reason).output.payload as unknown as ErrorPayload; +export const createConflictErrorPayload = (type: string, id: string, reason?: string) => + SavedObjectsErrorHelpers.createConflictError(type, id, reason).output + .payload as unknown as ErrorPayload; +export const createGenericNotFoundErrorPayload = ( + type: string | null = null, + id: string | null = null +) => + SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output + .payload as unknown as ErrorPayload; +export const createUnsupportedTypeErrorPayload = (type: string) => + SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output + .payload as unknown as ErrorPayload; + +export const expectError = ({ type, id }: { type: string; id: string }) => ({ + type, + id, + error: expect.any(Object), +}); + +export const expectErrorResult = ( + { type, id }: TypeIdTuple, + error: Record, + overrides: Record = {} +): ExpectedErrorResult => ({ + type, + id, + error: { ...error, ...overrides }, +}); +export const expectErrorNotFound = (obj: TypeIdTuple, overrides?: Record) => + expectErrorResult(obj, createGenericNotFoundErrorPayload(obj.type, obj.id), overrides); +export const expectErrorConflict = (obj: TypeIdTuple, overrides?: Record) => + expectErrorResult(obj, createConflictErrorPayload(obj.type, obj.id), overrides); +export const expectErrorInvalidType = (obj: TypeIdTuple, overrides?: Record) => + expectErrorResult(obj, createUnsupportedTypeErrorPayload(obj.type), overrides); + +export const KIBANA_VERSION = '2.0.0'; +export const CUSTOM_INDEX_TYPE = 'customIndex'; +/** This type has namespaceType: 'agnostic'. */ +export const NAMESPACE_AGNOSTIC_TYPE = 'globalType'; +/** + * This type has namespaceType: 'multiple'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is shareable across + * namespaces. + **/ +export const MULTI_NAMESPACE_TYPE = 'multiNamespaceType'; +/** + * This type has namespaceType: 'multiple-isolated'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is NOT shareable + * across namespaces. This distinction only matters when using the `collectMultiNamespaceReferences` or `updateObjectsSpaces` APIs, or + * when using the `initialNamespaces` argument with the `create` and `bulkCreate` APIs. Those allow you to define or change what + * namespaces an object exists in. + * + * In a nutshell, this type is more restrictive than `MULTI_NAMESPACE_TYPE`, so we use `MULTI_NAMESPACE_ISOLATED_TYPE` for any test cases + * where `MULTI_NAMESPACE_TYPE` would also satisfy the test case. + **/ +export const MULTI_NAMESPACE_ISOLATED_TYPE = 'multiNamespaceIsolatedType'; +/** This type has namespaceType: 'multiple', and it uses a custom index. */ +export const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'multiNamespaceTypeCustomIndex'; +export const HIDDEN_TYPE = 'hiddenType'; +export const ENCRYPTED_TYPE = 'encryptedType'; +export const MULTI_NAMESPACE_ENCRYPTED_TYPE = 'multiNamespaceEncryptedType'; +export const mockVersionProps = { _seq_no: 1, _primary_term: 1 }; +export const mockVersion = encodeHitVersion(mockVersionProps); +export const mockTimestamp = '2017-08-14T15:49:14.886Z'; +export const mockTimestampFields = { updated_at: mockTimestamp }; +export const mockTimestampFieldsWithCreated = { + updated_at: mockTimestamp, + created_at: mockTimestamp, +}; +export const REMOVE_REFS_COUNT = 42; + +export interface TypeIdTuple { + id: string; + type: string; +} + +export const mappings: SavedObjectsTypeMappingDefinition = { + properties: { + config: { + properties: { + otherField: { + type: 'keyword', + }, + }, + }, + 'index-pattern': { + properties: { + someField: { + type: 'keyword', + }, + }, + }, + dashboard: { + properties: { + otherField: { + type: 'keyword', + }, + }, + }, + [CUSTOM_INDEX_TYPE]: { + properties: { + otherField: { + type: 'keyword', + }, + }, + }, + [NAMESPACE_AGNOSTIC_TYPE]: { + properties: { + yetAnotherField: { + type: 'keyword', + }, + }, + }, + [MULTI_NAMESPACE_TYPE]: { + properties: { + evenYetAnotherField: { + type: 'keyword', + }, + }, + }, + [MULTI_NAMESPACE_ISOLATED_TYPE]: { + properties: { + evenYetAnotherField: { + type: 'keyword', + }, + }, + }, + [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]: { + properties: { + evenYetAnotherField: { + type: 'keyword', + }, + }, + }, + [HIDDEN_TYPE]: { + properties: { + someField: { + type: 'keyword', + }, + }, + }, + [ENCRYPTED_TYPE]: { + properties: { + encryptedField: { + type: 'keyword', + }, + }, + }, + [MULTI_NAMESPACE_ENCRYPTED_TYPE]: { + properties: { + encryptedField: { + type: 'keyword', + }, + }, + }, + }, +}; + +export const authRecord: Record = { + find: { authorizedSpaces: ['bar'] }, +}; +export const authMap = Object.freeze(new Map([['foo', authRecord]])); + +export const checkAuthError = SavedObjectsErrorHelpers.createBadRequestError( + 'Failed to check authorization' +); + +export const enforceError = SavedObjectsErrorHelpers.decorateForbiddenError( + new Error('Unauthorized'), + 'User lacks privileges' +); + +export const setupCheckAuthorized = ( + mockSecurityExt: jest.Mocked +) => { + mockSecurityExt.checkAuthorization.mockResolvedValue({ + status: 'fully_authorized', + typeMap: authMap, + }); +}; + +export const setupCheckPartiallyAuthorized = ( + mockSecurityExt: jest.Mocked +) => { + mockSecurityExt.checkAuthorization.mockResolvedValue({ + status: 'partially_authorized', + typeMap: authMap, + }); +}; + +export const setupCheckUnauthorized = ( + mockSecurityExt: jest.Mocked +) => { + mockSecurityExt.checkAuthorization.mockResolvedValue({ + status: 'unauthorized', + typeMap: new Map([]), + }); +}; + +export const setupEnforceSuccess = ( + mockSecurityExt: jest.Mocked +) => { + mockSecurityExt.enforceAuthorization.mockImplementation( + (params: EnforceAuthorizationParams) => { + const { auditCallback } = params; + auditCallback?.(undefined); + } + ); +}; + +export const setupEnforceFailure = ( + mockSecurityExt: jest.Mocked +) => { + mockSecurityExt.enforceAuthorization.mockImplementation( + (params: EnforceAuthorizationParams) => { + const { auditCallback } = params; + auditCallback?.(enforceError); + throw enforceError; + } + ); +}; + +export const setupRedactPassthrough = ( + mockSecurityExt: jest.Mocked +) => { + mockSecurityExt.redactNamespaces.mockImplementation(({ savedObject: object }) => { + return object; + }); +}; + +export const createType = ( + type: string, + parts: Partial = {} +): SavedObjectsType => ({ + name: type, + hidden: false, + namespaceType: 'single', + mappings: { + properties: mappings.properties[type].properties! as SavedObjectsMappingProperties, + }, + migrations: { '1.1.1': (doc) => doc }, + ...parts, +}); + +export const createRegistry = () => { + const registry = new SavedObjectTypeRegistry(); + registry.registerType(createType('config')); + registry.registerType(createType('index-pattern')); + registry.registerType( + createType('dashboard', { + schemas: { + '8.0.0-testing': schema.object({ + title: schema.maybe(schema.string()), + otherField: schema.maybe(schema.string()), + }), + }, + }) + ); + registry.registerType(createType(CUSTOM_INDEX_TYPE, { indexPattern: 'custom' })); + registry.registerType(createType(NAMESPACE_AGNOSTIC_TYPE, { namespaceType: 'agnostic' })); + registry.registerType(createType(MULTI_NAMESPACE_TYPE, { namespaceType: 'multiple' })); + registry.registerType( + createType(MULTI_NAMESPACE_ISOLATED_TYPE, { namespaceType: 'multiple-isolated' }) + ); + registry.registerType( + createType(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, { + namespaceType: 'multiple', + indexPattern: 'custom', + }) + ); + registry.registerType( + createType(HIDDEN_TYPE, { + hidden: true, + namespaceType: 'agnostic', + }) + ); + registry.registerType( + createType(ENCRYPTED_TYPE, { + namespaceType: 'single', + }) + ); + registry.registerType( + createType(MULTI_NAMESPACE_ENCRYPTED_TYPE, { + namespaceType: 'multiple', + }) + ); + return registry; +}; + +export const createSpySerializer = (registry: SavedObjectTypeRegistry) => { + const spyInstance = { + isRawSavedObject: jest.fn(), + rawToSavedObject: jest.fn(), + savedObjectToRaw: jest.fn(), + generateRawId: jest.fn(), + generateRawLegacyUrlAliasId: jest.fn(), + trimIdPrefix: jest.fn(), + }; + const realInstance = new SavedObjectsSerializer(registry); + Object.keys(spyInstance).forEach((key) => { + // @ts-expect-error no proper way to do this with typing support + spyInstance[key].mockImplementation((...args) => realInstance[key](...args)); + }); + + return spyInstance as unknown as jest.Mocked; +}; + +export const createDocumentMigrator = (registry: SavedObjectTypeRegistry) => { + return new DocumentMigrator({ + typeRegistry: registry, + kibanaVersion: KIBANA_VERSION, + log: loggerMock.create(), + }); +}; + +export const getMockGetResponse = ( + registry: SavedObjectTypeRegistry, + { + type, + id, + references, + namespace: objectNamespace, + originId, + }: { + type: string; + id: string; + namespace?: string; + originId?: string; + references?: SavedObjectReference[]; + }, + namespace?: string | string[] +) => { + let namespaces; + if (objectNamespace) { + namespaces = [objectNamespace]; + } else if (namespace) { + namespaces = Array.isArray(namespace) ? namespace : [namespace]; + } else { + namespaces = ['default']; + } + const namespaceId = namespaces[0] === 'default' ? undefined : namespaces[0]; + + return { + // NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these + found: true, + _id: `${registry.isSingleNamespace(type) && namespaceId ? `${namespaceId}:` : ''}${type}:${id}`, + ...mockVersionProps, + _source: { + ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), + ...(registry.isMultiNamespace(type) && { namespaces }), + ...(originId && { originId }), + type, + [type]: + type !== ENCRYPTED_TYPE && type !== MULTI_NAMESPACE_ENCRYPTED_TYPE + ? { title: 'Testing' } + : { + title: 'Testing', + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references, + specialProperty: 'specialValue', + ...mockTimestampFields, + } as SavedObjectsRawDocSource, + } as estypes.GetResponse; +}; + +export const getMockMgetResponse = ( + registry: SavedObjectTypeRegistry, + objects: Array, + namespace?: string +) => + ({ + docs: objects.map((obj) => + obj.found === false + ? obj + : getMockGetResponse(registry, obj, obj.initialNamespaces ?? namespace) + ), + } as estypes.MgetResponse); + +expect.extend({ + toBeDocumentWithoutError(received, type, id) { + if (received.type === type && received.id === id && !received.error) { + return { message: () => `expected type and id not to match without error`, pass: true }; + } else { + return { message: () => `expected type and id to match without error`, pass: false }; + } + }, +}); + +export const mockUpdateResponse = ( + client: ElasticsearchClientMock, + type: string, + id: string, + options?: SavedObjectsUpdateOptions, + namespaces?: string[], + originId?: string +) => { + client.update.mockResponseOnce( + { + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + // don't need the rest of the source for test purposes, just the namespace and namespaces attributes + get: { + _source: { + namespaces: namespaces ?? [options?.namespace ?? 'default'], + namespace: options?.namespace, + + // If the existing saved object contains an originId attribute, the operation will return it in the result. + // The originId parameter is just used for test purposes to modify the mock cluster call response. + ...(!!originId && { originId }), + }, + }, + } as estypes.UpdateResponse, + { statusCode: 200 } + ); +}; + +export const updateSuccess = async ( + client: ElasticsearchClientMock, + repository: SavedObjectsRepository, + registry: SavedObjectTypeRegistry, + type: string, + id: string, + attributes: T, + options?: SavedObjectsUpdateOptions, + internalOptions: { + originId?: string; + mockGetResponseValue?: estypes.GetResponse; + } = {}, + objNamespaces?: string[] +) => { + const { mockGetResponseValue, originId } = internalOptions; + if (registry.isMultiNamespace(type)) { + const mockGetResponse = + mockGetResponseValue ?? + getMockGetResponse(registry, { type, id }, objNamespaces ?? options?.namespace); + client.get.mockResponseOnce(mockGetResponse, { statusCode: 200 }); + } + mockUpdateResponse(client, type, id, options, objNamespaces, originId); + const result = await repository.update(type, id, attributes, options); + expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0); + return result; +}; + +export const bulkGet = async ( + repository: SavedObjectsRepository, + objects: SavedObjectsBulkGetObject[], + options?: SavedObjectsBaseOptions +) => + repository.bulkGet( + objects.map(({ type, id, namespaces }) => ({ type, id, namespaces })), // bulkGet only uses type, id, and optionally namespaces + options + ); + +export const bulkGetSuccess = async ( + client: ElasticsearchClientMock, + repository: SavedObjectsRepository, + registry: SavedObjectTypeRegistry, + objects: SavedObject[], + options?: SavedObjectsBaseOptions +) => { + const mockResponse = getMockMgetResponse(registry, objects, options?.namespace); + client.mget.mockResponseOnce(mockResponse); + const result = await bulkGet(repository, objects, options); + expect(client.mget).toHaveBeenCalledTimes(1); + return { result, mockResponse }; +}; + +export const expectBulkGetResult = ( + { type, id }: TypeIdTuple, + doc: estypes.GetGetResult +) => ({ + type, + id, + namespaces: doc._source!.namespaces ?? [doc._source!.namespace] ?? ['default'], + ...(doc._source!.originId && { originId: doc._source!.originId }), + ...(doc._source!.updated_at && { updated_at: doc._source!.updated_at }), + version: encodeHitVersion(doc), + attributes: doc._source![type], + references: doc._source!.references || [], + migrationVersion: doc._source!.migrationVersion, +}); + +export const getMockBulkCreateResponse = ( + objects: SavedObjectsBulkCreateObject[], + namespace?: string +) => { + return { + errors: false, + took: 1, + items: objects.map(({ type, id, originId, attributes, references, migrationVersion }) => ({ + create: { + // status: 1, + // _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, + _source: { + [type]: attributes, + type, + namespace, + ...(originId && { originId }), + references, + ...mockTimestampFieldsWithCreated, + migrationVersion: migrationVersion || { [type]: '1.1.1' }, + }, + ...mockVersionProps, + }, + })), + } as unknown as estypes.BulkResponse; +}; + +export const bulkCreateSuccess = async ( + client: ElasticsearchClientMock, + repository: SavedObjectsRepository, + objects: SavedObjectsBulkCreateObject[], + options?: SavedObjectsCreateOptions +) => { + const mockResponse = getMockBulkCreateResponse(objects, options?.namespace); + client.bulk.mockResponse(mockResponse); + const result = await repository.bulkCreate(objects, options); + return result; +}; + +export const expectCreateResult = (obj: { + type: string; + namespace?: string; + namespaces?: string[]; +}) => ({ + ...obj, + migrationVersion: { [obj.type]: '1.1.1' }, + coreMigrationVersion: KIBANA_VERSION, + version: mockVersion, + namespaces: obj.namespaces ?? [obj.namespace ?? 'default'], + ...mockTimestampFieldsWithCreated, +}); + +export const getMockBulkUpdateResponse = ( + registry: SavedObjectTypeRegistry, + objects: TypeIdTuple[], + options?: SavedObjectsBulkUpdateOptions, + originId?: string +) => + ({ + items: objects.map(({ type, id }) => ({ + update: { + _id: `${ + registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : '' + }${type}:${id}`, + ...mockVersionProps, + get: { + _source: { + // If the existing saved object contains an originId attribute, the operation will return it in the result. + // The originId parameter is just used for test purposes to modify the mock cluster call response. + ...(!!originId && { originId }), + }, + }, + result: 'updated', + }, + })), + } as estypes.BulkResponse); + +export const bulkUpdateSuccess = async ( + client: ElasticsearchClientMock, + repository: SavedObjectsRepository, + registry: SavedObjectTypeRegistry, + objects: SavedObjectsBulkUpdateObject[], + options?: SavedObjectsBulkUpdateOptions, + originId?: string, + multiNamespaceSpace?: string // the space for multi namespace objects returned by mock mget (this is only needed for space ext testing) +) => { + const multiNamespaceObjects = objects.filter(({ type }) => registry.isMultiNamespace(type)); + if (multiNamespaceObjects?.length) { + const response = getMockMgetResponse( + registry, + multiNamespaceObjects, + multiNamespaceSpace ?? options?.namespace + ); + client.mget.mockResponseOnce(response); + } + const response = getMockBulkUpdateResponse(registry, objects, options, originId); + client.bulk.mockResponseOnce(response); + const result = await repository.bulkUpdate(objects, options); + expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); + return result; +}; + +export const expectUpdateResult = ({ + type, + id, + attributes, + references, +}: SavedObjectsBulkUpdateObject) => ({ + type, + id, + attributes, + references, + version: mockVersion, + namespaces: ['default'], + ...mockTimestampFields, +}); + +export type IGenerateSearchResultsFunction = ( + namespace?: string +) => estypes.SearchResponse; + +export const generateIndexPatternSearchResults = (namespace?: string) => { + return { + took: 1, + timed_out: false, + _shards: {} as any, + hits: { + total: 4, + hits: [ + { + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}index-pattern:logstash-*`, + _score: 1, + ...mockVersionProps, + _source: { + namespace, + originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { + title: 'logstash-*', + timeFieldName: '@timestamp', + notExpandable: true, + }, + }, + }, + { + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}config:6.0.0-alpha1`, + _score: 2, + ...mockVersionProps, + _source: { + namespace, + type: 'config', + ...mockTimestampFields, + config: { + buildNum: 8467, + defaultIndex: 'logstash-*', + }, + }, + }, + { + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}index-pattern:stocks-*`, + _score: 3, + ...mockVersionProps, + _source: { + namespace, + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { + title: 'stocks-*', + timeFieldName: '@timestamp', + notExpandable: true, + }, + }, + }, + { + _index: '.kibana', + _id: `${NAMESPACE_AGNOSTIC_TYPE}:something`, + _score: 4, + ...mockVersionProps, + _source: { + type: NAMESPACE_AGNOSTIC_TYPE, + ...mockTimestampFields, + [NAMESPACE_AGNOSTIC_TYPE]: { + name: 'bar', + }, + }, + }, + ], + }, + } as estypes.SearchResponse; +}; + +export const findSuccess = async ( + client: ElasticsearchClientMock, + repository: SavedObjectsRepository, + options: SavedObjectsFindOptions, + namespace?: string, + generateSearchResultsFunc: IGenerateSearchResultsFunction = generateIndexPatternSearchResults +) => { + const generatedResults = generateSearchResultsFunc(namespace); + client.search.mockResponseOnce(generatedResults); + const result = await repository.find(options); + expect(mockGetSearchDsl).toHaveBeenCalledTimes(1); + expect(client.search).toHaveBeenCalledTimes(1); + return { result, generatedResults }; +}; + +export const deleteSuccess = async ( + client: ElasticsearchClientMock, + repository: SavedObjectsRepository, + registry: SavedObjectTypeRegistry, + type: string, + id: string, + options?: SavedObjectsDeleteOptions, + internalOptions: { mockGetResponseValue?: estypes.GetResponse } = {} +) => { + const { mockGetResponseValue } = internalOptions; + if (registry.isMultiNamespace(type)) { + const mockGetResponse = + mockGetResponseValue ?? getMockGetResponse(registry, { type, id }, options?.namespace); + client.get.mockResponseOnce(mockGetResponse); + } + client.delete.mockResponseOnce({ + result: 'deleted', + } as estypes.DeleteResponse); + const result = await repository.delete(type, id, options); + expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0); + return result; +}; + +export const removeReferencesToSuccess = async ( + client: ElasticsearchClientMock, + repository: SavedObjectsRepository, + type: string, + id: string, + options = {}, + updatedCount = REMOVE_REFS_COUNT +) => { + client.updateByQuery.mockResponseOnce({ + updated: updatedCount, + }); + return await repository.removeReferencesTo(type, id, options); +}; + +export const checkConflicts = async ( + repository: SavedObjectsRepository, + objects: TypeIdTuple[], + options?: SavedObjectsBaseOptions +) => + repository.checkConflicts( + objects.map(({ type, id }) => ({ type, id })), // checkConflicts only uses type and id + options + ); + +export const checkConflictsSuccess = async ( + client: ElasticsearchClientMock, + repository: SavedObjectsRepository, + registry: SavedObjectTypeRegistry, + objects: TypeIdTuple[], + options?: SavedObjectsBaseOptions +) => { + const response = getMockMgetResponse(registry, objects, options?.namespace); + client.mget.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); + const result = await checkConflicts(repository, objects, options); + expect(client.mget).toHaveBeenCalledTimes(1); + return result; +}; + +export const getSuccess = async ( + client: ElasticsearchClientMock, + repository: SavedObjectsRepository, + registry: SavedObjectTypeRegistry, + type: string, + id: string, + options?: SavedObjectsBaseOptions, + originId?: string, + objNamespaces?: string[] +) => { + const response = getMockGetResponse( + registry, + { + type, + id, + // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the + // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. + originId, + }, + objNamespaces ?? options?.namespace + ); + client.get.mockResponseOnce(response); + const result = await repository.get(type, id, options); + expect(client.get).toHaveBeenCalledTimes(1); + return result; +}; + +export function setsAreEqual(setA: Set, setB: Set) { + return isEqual(Array(setA).sort(), Array(setB).sort()); +} + +export function typeMapsAreEqual(mapA: Map>, mapB: Map>) { + return ( + mapA.size === mapB.size && + Array.from(mapA.keys()).every((key) => setsAreEqual(mapA.get(key)!, mapB.get(key)!)) + ); +} + +export function namespaceMapsAreEqual( + mapA: Map, + mapB: Map +) { + return ( + mapA.size === mapB.size && + Array.from(mapA.keys()).every((key) => isEqual(mapA.get(key)?.sort(), mapB.get(key)?.sort())) + ); +} + +export const getMockEsBulkDeleteResponse = ( + registry: SavedObjectTypeRegistry, + objects: TypeIdTuple[], + options?: SavedObjectsBulkDeleteOptions +) => + ({ + items: objects.map(({ type, id }) => ({ + // es response returns more fields than what we're interested in. + delete: { + _id: `${ + registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : '' + }${type}:${id}`, + ...mockVersionProps, + result: 'deleted', + }, + })), + } as estypes.BulkResponse); + +export const bulkDeleteSuccess = async ( + client: ElasticsearchClientMock, + repository: SavedObjectsRepository, + registry: SavedObjectTypeRegistry, + objects: SavedObjectsBulkDeleteObject[] = [], + options?: SavedObjectsBulkDeleteOptions, + internalOptions: { + mockMGetResponseObjects?: Array<{ + initialNamespaces: string[] | undefined; + type: string; + id: string; + }>; + } = {} +) => { + const multiNamespaceObjects = objects.filter(({ type }) => { + return registry.isMultiNamespace(type); + }); + + const { mockMGetResponseObjects } = internalOptions; + if (multiNamespaceObjects.length > 0) { + const mockedMGetResponse = mockMGetResponseObjects + ? getMockMgetResponse(registry, mockMGetResponseObjects, options?.namespace) + : getMockMgetResponse(registry, multiNamespaceObjects, options?.namespace); + client.mget.mockResponseOnce(mockedMGetResponse); + } + const mockedEsBulkDeleteResponse = getMockEsBulkDeleteResponse(registry, objects, options); + + client.bulk.mockResponseOnce(mockedEsBulkDeleteResponse); + const result = await repository.bulkDelete(objects, options); + + expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); + return result; +}; + +export const createBulkDeleteSuccessStatus = ({ type, id }: { type: string; id: string }) => ({ + type, + id, + success: true, +}); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-mocks/index.ts b/packages/core/saved-objects/core-saved-objects-api-server-mocks/index.ts index b558e2d93d6d9..9ba340e600f6c 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-mocks/index.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-mocks/index.ts @@ -10,4 +10,5 @@ export { savedObjectsClientMock, savedObjectsRepositoryMock, savedObjectsClientProviderMock, + savedObjectsExtensionsMock, } from './src'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/index.ts b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/index.ts index 7dad32d20a57f..8436953f25558 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/index.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/index.ts @@ -9,3 +9,4 @@ export { savedObjectsClientMock } from './saved_objects_client.mock'; export { savedObjectsRepositoryMock } from './repository.mock'; export { savedObjectsClientProviderMock } from './scoped_client_provider.mock'; +export { savedObjectsExtensionsMock } from './saved_objects_extensions.mock'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/point_in_time_finder.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/point_in_time_finder.mock.ts index b14715db34ddd..2bbc4920ecab1 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/point_in_time_finder.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/point_in_time_finder.mock.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; import type { SavedObjectsClientContract, ISavedObjectsRepository, diff --git a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_extensions.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_extensions.mock.ts new file mode 100644 index 0000000000000..f4308ee6254c7 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_extensions.mock.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ISavedObjectsEncryptionExtension, + ISavedObjectsSecurityExtension, + ISavedObjectsSpacesExtension, + SavedObjectsExtensions, +} from '@kbn/core-saved-objects-server'; + +const createEncryptionExtension = (): jest.Mocked => ({ + isEncryptableType: jest.fn(), + decryptOrStripResponseAttributes: jest.fn(), + encryptAttributes: jest.fn(), +}); + +const createSecurityExtension = (): jest.Mocked => ({ + checkAuthorization: jest.fn(), + enforceAuthorization: jest.fn(), + addAuditEvent: jest.fn(), + redactNamespaces: jest.fn(), +}); + +const createSpacesExtension = (): jest.Mocked => ({ + getCurrentNamespace: jest.fn(), + getSearchableNamespaces: jest.fn(), +}); + +const create = (): jest.Mocked => ({ + encryptionExtension: createEncryptionExtension(), + securityExtension: createSecurityExtension(), + spacesExtension: createSpacesExtension(), +}); + +export const savedObjectsExtensionsMock = { + create, + createEncryptionExtension, + createSecurityExtension, + createSpacesExtension, +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/scoped_client_provider.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/scoped_client_provider.mock.ts index dd617cdbf3d3c..73efc414a634a 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/scoped_client_provider.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/scoped_client_provider.mock.ts @@ -9,9 +9,9 @@ import type { ISavedObjectsClientProvider } from '@kbn/core-saved-objects-api-server-internal'; const create = (): jest.Mocked => ({ - addClientWrapperFactory: jest.fn(), getClient: jest.fn(), setClientFactory: jest.fn(), + getExtensions: jest.fn(), }); export const savedObjectsClientProviderMock = { diff --git a/packages/core/saved-objects/core-saved-objects-api-server/index.ts b/packages/core/saved-objects/core-saved-objects-api-server/index.ts index fdaa5685fbde0..467b5891ffb85 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/index.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/index.ts @@ -7,7 +7,10 @@ */ export type { SavedObjectsClientContract } from './src/saved_objects_client'; -export type { ISavedObjectsRepository } from './src/saved_objects_repository'; +export type { + ISavedObjectsRepository, + SavedObjectsFindInternalOptions, +} from './src/saved_objects_repository'; export type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions, diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/base.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/base.ts index e02a4142448ef..eb80df8d96d44 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/base.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/base.ts @@ -24,9 +24,11 @@ export interface SavedObjectsBaseOptions { export type MutatingOperationRefreshSetting = boolean | 'wait_for'; /** + * Base return for saved object bulk operations * * @public */ export interface SavedObjectsBulkResponse { + /** array of saved objects */ saved_objects: Array>; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_create.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_create.ts index 735564cfec63d..7a38a909155ff 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_create.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_create.ts @@ -12,14 +12,20 @@ import type { } from '@kbn/core-saved-objects-common'; /** + * Object parameters for the bulk create operation * * @public */ export interface SavedObjectsBulkCreateObject { + /** Optional ID of the object to create (the ID is generated by default) */ id?: string; + /** The type of object to create */ type: string; + /** The attributes for the object to create */ attributes: T; + /** The version string for the object to create */ version?: string; + /** Array of references to other saved objects */ references?: SavedObjectReference[]; /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_delete.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_delete.ts index 76d490925c580..5b390cca73d14 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_delete.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_delete.ts @@ -10,15 +10,20 @@ import type { SavedObjectError } from '@kbn/core-saved-objects-common'; import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base'; /** + * Object parameters for the bulk delete operation * * @public */ export interface SavedObjectsBulkDeleteObject { + /** The type of the saved object to delete */ type: string; + /** The ID of the saved object to delete */ id: string; } /** + * Options for the bulk delete operation + * * @public */ export interface SavedObjectsBulkDeleteOptions extends SavedObjectsBaseOptions { @@ -31,10 +36,14 @@ export interface SavedObjectsBulkDeleteOptions extends SavedObjectsBaseOptions { } /** + * The per-object result of a bulk delete operation + * * @public */ export interface SavedObjectsBulkDeleteStatus { + /** The ID of the saved object */ id: string; + /** The type of the saved object */ type: string; /** The status of deleting the object: true for deleted, false for error */ success: boolean; @@ -43,8 +52,11 @@ export interface SavedObjectsBulkDeleteStatus { } /** + * Return type of the Saved Objects `bulkDelete()` method + * * @public */ export interface SavedObjectsBulkDeleteResponse { + /** Array of {@link SavedObjectsBulkDeleteStatus} */ statuses: SavedObjectsBulkDeleteStatus[]; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_get.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_get.ts index fbbe6c958594d..1a3f4928db672 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_get.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_get.ts @@ -7,11 +7,14 @@ */ /** + * Object parameters for the bulk get operation * * @public */ export interface SavedObjectsBulkGetObject { + /** ID of the object to get */ id: string; + /** Type of the object to get */ type: string; /** SavedObject fields to include in the response */ fields?: string[]; diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_resolve.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_resolve.ts index b52c8caf26e82..ff59adee8be7f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_resolve.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_resolve.ts @@ -9,18 +9,23 @@ import type { SavedObjectsResolveResponse } from './resolve'; /** + * Object parameters for the bulk resolve operation * * @public */ export interface SavedObjectsBulkResolveObject { + /** ID of the object to resiolve */ id: string; + /** Type of the object to resolve */ type: string; } /** + * Return type of the Saved Objects `bulkResolve()` method. * * @public */ export interface SavedObjectsBulkResolveResponse { + /** array of {@link SavedObjectsResolveResponse} */ resolved_objects: Array>; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts index 858504853dd75..6d10aee397b2f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts @@ -10,6 +10,7 @@ import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from '. import type { SavedObjectsUpdateOptions, SavedObjectsUpdateResponse } from './update'; /** + * Object parameters for the bulk update operation * * @public */ @@ -31,6 +32,7 @@ export interface SavedObjectsBulkUpdateObject } /** + * Options for the saved objects bulk update operation * * @public */ @@ -40,9 +42,11 @@ export interface SavedObjectsBulkUpdateOptions extends SavedObjectsBaseOptions { } /** + * Return type of the Saved Objects `bulkUpdate()` method. * * @public */ export interface SavedObjectsBulkUpdateResponse { + /** array of {@link SavedObjectsUpdateResponse} */ saved_objects: Array>; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/check_conflicts.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/check_conflicts.ts index 38d1c281b1000..331b95519a91c 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/check_conflicts.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/check_conflicts.ts @@ -9,19 +9,24 @@ import type { SavedObjectError } from '@kbn/core-saved-objects-common'; /** + * Object parameters for the check conficts operation * * @public */ export interface SavedObjectsCheckConflictsObject { + /** The ID of the object to check */ id: string; + /** The type of the object to check */ type: string; } /** + * Return type of the Saved Objects `checkConflicts()` method. * * @public */ export interface SavedObjectsCheckConflictsResponse { + /** Array of errors (contains the conflicting object ID, type, and error details) */ errors: Array<{ id: string; type: string; diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/close_point_in_time.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/close_point_in_time.ts index 16348155003d2..8996de4474cfe 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/close_point_in_time.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/close_point_in_time.ts @@ -9,21 +9,20 @@ import type { SavedObjectsBaseOptions } from './base'; /** + * Options for the close point-in-time operation + * * @public */ export type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions; /** + * Return type of the Saved Objects `closePointInTime()` method. + * * @public */ export interface SavedObjectsClosePointInTimeResponse { - /** - * If true, all search contexts associated with the PIT id are - * successfully closed. - */ + /** If true, all search contexts associated with the PIT id are successfully closed */ succeeded: boolean; - /** - * The number of search contexts that have been successfully closed. - */ + /** The number of search contexts that have been successfully closed */ num_freed: number; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/collect_multinamespace_references.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/collect_multinamespace_references.ts index 59bfd4144a8c1..fcd0d079961bd 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/collect_multinamespace_references.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/collect_multinamespace_references.ts @@ -18,7 +18,9 @@ import type { SavedObjectsBaseOptions } from './base'; * @public */ export interface SavedObjectsCollectMultiNamespaceReferencesObject { + /** The ID of the object to collect references for */ id: string; + /** The type of the object to collect references for */ type: string; } @@ -73,5 +75,6 @@ export interface SavedObjectReferenceWithContext { * @public */ export interface SavedObjectsCollectMultiNamespaceReferencesResponse { + /** array of {@link SavedObjectReferenceWithContext} */ objects: SavedObjectReferenceWithContext[]; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/create.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/create.ts index 2172b75be4b46..78a017ed03aba 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/create.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/create.ts @@ -13,6 +13,7 @@ import type { import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base'; /** + * Options for the saved objects create operation * * @public */ @@ -38,6 +39,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * field set and you want to create it again. */ coreMigrationVersion?: string; + /** Array of references to other saved objects */ references?: SavedObjectReference[]; /** The Elasticsearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/create_point_in_time_finder.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/create_point_in_time_finder.ts index e46a259dfd4cf..7cbf77467c8e3 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/create_point_in_time_finder.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/create_point_in_time_finder.ts @@ -7,9 +7,11 @@ */ import type { SavedObjectsFindOptions, SavedObjectsFindResponse } from './find'; -import type { SavedObjectsClientContract } from '../saved_objects_client'; +import type { ISavedObjectsRepository } from '../saved_objects_repository'; /** + * Options for the create point-in-time finder operation + * * @public */ export type SavedObjectsCreatePointInTimeFinderOptions = Omit< @@ -18,21 +20,31 @@ export type SavedObjectsCreatePointInTimeFinderOptions = Omit< >; /** + * Point-in-time finder client. + * Partially implements {@link ISavedObjectsRepository} + * * @public */ export type SavedObjectsPointInTimeFinderClient = Pick< - SavedObjectsClientContract, + ISavedObjectsRepository, 'find' | 'openPointInTimeForType' | 'closePointInTime' >; /** + * Dependencies for the create point-in-time finder operation + * * @public */ export interface SavedObjectsCreatePointInTimeFinderDependencies { + /** the point-in-time finder client */ client: SavedObjectsPointInTimeFinderClient; } -/** @public */ +/** + * Point-in-time finder + * + * @public + */ export interface ISavedObjectsPointInTimeFinder { /** * An async generator which wraps calls to `savedObjectsClient.find` and diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/find.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/find.ts index a50506c96c8e5..8e754d26533c3 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/find.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/find.ts @@ -16,30 +16,44 @@ import type { SavedObject } from '@kbn/core-saved-objects-common'; type KueryNode = any; /** + * An object reference for use in find operation options + * * @public */ export interface SavedObjectsFindOptionsReference { + /** The type of the saved object */ type: string; + /** The ID of the saved object */ id: string; } /** + * Point-in-time parameters + * * @public */ export interface SavedObjectsPitParams { + /** The ID of point-in-time */ id: string; + /** Optionally specify how long ES should keep the PIT alive until the next request. Defaults to `5m`. */ keepAlive?: string; } /** + * Options for finding saved objects * * @public */ export interface SavedObjectsFindOptions { + /** the type or types of objects to find */ type: string | string[]; + /** the page of results to return */ page?: number; + /** the number of objects per page */ perPage?: number; + /** which field to sort by */ sortField?: string; + /** sort order, ascending or descending */ sortOrder?: SortOrder; /** * An array of fields to include in the results @@ -60,33 +74,29 @@ export interface SavedObjectsFindOptions { * be modified. If used in conjunction with `searchFields`, both are concatenated together. */ rootSearchFields?: string[]; - /** * Search for documents having a reference to the specified objects. * Use `hasReferenceOperator` to specify the operator to use when searching for multiple references. */ hasReference?: SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[]; - /** * The operator to use when searching by multiple references using the `hasReference` option. Defaults to `OR` */ hasReferenceOperator?: 'AND' | 'OR'; - /** * Search for documents *not* having a reference to the specified objects. * Use `hasNoReferenceOperator` to specify the operator to use when searching for multiple references. */ hasNoReference?: SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[]; - /** * The operator to use when searching by multiple references using the `hasNoReference` option. Defaults to `OR` */ hasNoReferenceOperator?: 'AND' | 'OR'; - /** * The search operator to use with the provided filter. Defaults to `OR` */ defaultSearchOperator?: 'AND' | 'OR'; + /** filter string for the search query */ filter?: string | KueryNode; /** * A record of aggregations to perform. @@ -110,6 +120,7 @@ export interface SavedObjectsFindOptions { * @alpha */ aggs?: Record; + /** array of namespaces to search */ namespaces?: string[]; /** * This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved @@ -128,6 +139,7 @@ export interface SavedObjectsFindOptions { } /** + * Results for a find operation * * @public */ @@ -176,10 +188,16 @@ export interface SavedObjectsFindResult extends SavedObject { * @public */ export interface SavedObjectsFindResponse { + /** aggregations from the search query response */ aggregations?: A; + /** array of found saved objects */ saved_objects: Array>; + /** the total number of objects */ total: number; + /** the number of objects per page */ per_page: number; + /** the current page number */ page: number; + /** the point-in-time ID (undefined if not applicable) */ pit_id?: string; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/increment_counter.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/increment_counter.ts index 8a87bb7b38b06..17234d51a6fce 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/increment_counter.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/increment_counter.ts @@ -10,6 +10,8 @@ import type { SavedObjectsMigrationVersion } from '@kbn/core-saved-objects-commo import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base'; /** + * Options for the increment counter operation + * * @public */ export interface SavedObjectsIncrementCounterOptions @@ -33,6 +35,8 @@ export interface SavedObjectsIncrementCounterOptions } /** + * The field and increment details for the increment counter operation + * * @public */ export interface SavedObjectsIncrementCounterField { diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/open_point_in_time_for_type.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/open_point_in_time_for_type.ts index ad9a2face8b06..3703cd67db1be 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/open_point_in_time_for_type.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/open_point_in_time_for_type.ts @@ -7,6 +7,8 @@ */ /** + * Options for the open point-in-time for type operation + * * @public */ export interface SavedObjectsOpenPointInTimeOptions { @@ -30,11 +32,11 @@ export interface SavedObjectsOpenPointInTimeOptions { } /** + * Return type of the Saved Objects `openPointInTimeForType()` method. + * * @public */ export interface SavedObjectsOpenPointInTimeResponse { - /** - * PIT ID returned from ES. - */ + /** PIT ID returned from ES */ id: string; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/update.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/update.ts index 31bac37b94fff..db494d9d8d7a7 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/update.ts @@ -10,6 +10,7 @@ import type { SavedObjectReference, SavedObject } from '@kbn/core-saved-objects- import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base'; /** + * Options for the saved objects update operation * * @public */ @@ -23,7 +24,7 @@ export interface SavedObjectsUpdateOptions extends SavedOb references?: SavedObjectReference[]; /** The Elasticsearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; - /** If specified, will be used to perform an upsert if the document doesn't exist */ + /** If specified, will be used to perform an upsert if the object doesn't exist */ upsert?: Attributes; /** * The Elasticsearch `retry_on_conflict` setting for this operation. @@ -33,11 +34,14 @@ export interface SavedObjectsUpdateOptions extends SavedOb } /** + * Return type of the Saved Objects `update()` method. * * @public */ export interface SavedObjectsUpdateResponse extends Omit, 'attributes' | 'references'> { + /** partial attributes of the saved object */ attributes: Partial; + /** optionally included references to other saved objects */ references: SavedObjectReference[] | undefined; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/update_objects_spaces.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/update_objects_spaces.ts index 148d3480f2975..a249ef50f418f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/update_objects_spaces.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/update_objects_spaces.ts @@ -49,6 +49,7 @@ export interface SavedObjectsUpdateObjectsSpacesOptions extends SavedObjectsBase * @public */ export interface SavedObjectsUpdateObjectsSpacesResponse { + /** array of {@link SavedObjectsUpdateObjectsSpacesResponseObject} */ objects: SavedObjectsUpdateObjectsSpacesResponseObject[]; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/saved_objects_client.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/saved_objects_client.ts index cfe1f4e6a146b..de7fcfd19fc4b 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/saved_objects_client.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/saved_objects_client.ts @@ -112,9 +112,10 @@ export interface SavedObjectsClientContract { /** * Persists a SavedObject * - * @param type - * @param attributes - * @param options + * @param type - the type of saved object to create + * @param attributes - attributes for the saved object + * @param options {@link SavedObjectsCreateOptions} - options for the create operation + * @returns the created saved object */ create( type: string, @@ -125,8 +126,9 @@ export interface SavedObjectsClientContract { /** * Persists multiple documents batched together as a single request * - * @param objects - * @param options + * @param objects - array of objects to create (contains type, attributes, and optional fields ) + * @param options {@link SavedObjectsCreateOptions} - options for the bulk create operation + * @returns the {@link SavedObjectsBulkResponse} */ bulkCreate( objects: Array>, @@ -137,8 +139,9 @@ export interface SavedObjectsClientContract { * Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are * multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. * - * @param objects - * @param options + * @param objects - array of objects to check (contains ID and type) + * @param options {@link SavedObjectsBaseOptions} - options for the check conflicts operation + * @returns the {@link SavedObjectsCheckConflictsResponse} */ checkConflicts( objects: SavedObjectsCheckConflictsObject[], @@ -148,17 +151,18 @@ export interface SavedObjectsClientContract { /** * Deletes a SavedObject * - * @param type - * @param id - * @param options + * @param type - the type of saved object to delete + * @param id - the ID of the saved object to delete + * @param options {@link SavedObjectsDeleteOptions} - options for the delete operation */ delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; /** * Deletes multiple SavedObjects batched together as a single request * - * @param objects - * @param options + * @param objects - array of objects to delete (contains ID and type) + * @param options {@link SavedObjectsBulkDeleteOptions} - options for the bulk delete operation + * @returns the {@link SavedObjectsBulkDeleteResponse} */ bulkDelete( objects: SavedObjectsBulkDeleteObject[], @@ -167,7 +171,8 @@ export interface SavedObjectsClientContract { /** * Find all SavedObjects matching the search query * - * @param options + * @param options {@link SavedObjectsFindOptions} - options for the find operation + * @returns the {@link SavedObjectsFindResponse} */ find( options: SavedObjectsFindOptions @@ -176,7 +181,9 @@ export interface SavedObjectsClientContract { /** * Returns an array of objects by id * - * @param objects - an array of ids, or an array of objects containing id, type and optionally fields + * @param objects - array of objects to get (contains id, type, and optional fields) + * @param options {@link SavedObjectsBaseOptions} - options for the bulk get operation + * @returns the {@link SavedObjectsBulkResponse} * @example * * bulkGet([ @@ -192,9 +199,9 @@ export interface SavedObjectsClientContract { /** * Retrieves a single object * - * @param type - The type of SavedObject to retrieve - * @param id - The ID of the SavedObject to retrieve - * @param options + * @param type - The type of the object to retrieve + * @param id - The ID of the object to retrieve + * @param options {@link SavedObjectsBaseOptions} - options for the get operation */ get( type: string, @@ -205,7 +212,9 @@ export interface SavedObjectsClientContract { /** * Resolves an array of objects by id, using any legacy URL aliases if they exist * - * @param objects - an array of objects containing id, type + * @param objects - an array of objects to resolve (contains id and type) + * @param options {@link SavedObjectsBaseOptions} - options for the bulk resolve operation + * @returns the {@link SavedObjectsBulkResolveResponse} * @example * * bulkResolve([ @@ -227,7 +236,8 @@ export interface SavedObjectsClientContract { * * @param type - The type of SavedObject to retrieve * @param id - The ID of the SavedObject to retrieve - * @param options + * @param options {@link SavedObjectsBaseOptions} - options for the resolve operation + * @returns the {@link SavedObjectsResolveResponse} */ resolve( type: string, @@ -238,9 +248,11 @@ export interface SavedObjectsClientContract { /** * Updates an SavedObject * - * @param type - * @param id - * @param options + * @param type - The type of SavedObject to update + * @param id - The ID of the SavedObject to update + * @param attributes - Attributes to update + * @param options {@link SavedObjectsUpdateOptions} - options for the update operation + * @returns the {@link SavedObjectsUpdateResponse} */ update( type: string, @@ -252,7 +264,9 @@ export interface SavedObjectsClientContract { /** * Bulk Updates multiple SavedObject at once * - * @param objects + * @param objects - array of objects to update (contains ID, type, attributes, and optional namespace) + * @param options {@link SavedObjectsBulkUpdateOptions} - options for the bulkUpdate operation + * @returns the {@link SavedObjectsBulkUpdateResponse} */ bulkUpdate( objects: Array>, @@ -261,6 +275,11 @@ export interface SavedObjectsClientContract { /** * Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. + * + * @param type - the type of the object to remove references to + * @param id - the ID of the object to remove references to + * @param options {@link SavedObjectsRemoveReferencesToOptions} - options for the remove references opertion + * @returns the {@link SavedObjectsRemoveReferencesToResponse} */ removeReferencesTo( type: string, @@ -275,6 +294,10 @@ export interface SavedObjectsClientContract { * * Only use this API if you have an advanced use case that's not solved by the * {@link SavedObjectsClient.createPointInTimeFinder} method. + * + * @param type - the type or array of types + * @param options {@link SavedObjectsOpenPointInTimeOptions} - options for the open PIT for type operation + * @returns the {@link SavedObjectsOpenPointInTimeResponse} */ openPointInTimeForType( type: string | string[], @@ -288,6 +311,10 @@ export interface SavedObjectsClientContract { * * Only use this API if you have an advanced use case that's not solved by the * {@link SavedObjectsClient.createPointInTimeFinder} method. + * + * @param id - the ID of the PIT to close + * @param options {@link SavedObjectsClosePointInTimeOptions} - options for the close PIT operation + * @returns the {@link SavedObjectsClosePointInTimeResponse} */ closePointInTime( id: string, @@ -338,6 +365,10 @@ export interface SavedObjectsClientContract { * } * } * ``` + * + * @param findOptions {@link SavedObjectsCreatePointInTimeFinderOptions} - options for the create PIT finder operation + * @param dependencies {@link SavedObjectsCreatePointInTimeFinderDependencies} - dependencies for the create PIT fimder operation + * @returns the created PIT finder */ createPointInTimeFinder( findOptions: SavedObjectsCreatePointInTimeFinderOptions, @@ -347,8 +378,9 @@ export interface SavedObjectsClientContract { /** * Gets all references and transitive references of the listed objects. Ignores any object that is not a multi-namespace type. * - * @param objects - * @param options + * @param objects - array of objects to collect references for (contains ID and type) + * @param options {@link SavedObjectsCollectMultiNamespaceReferencesOptions} - options for the collect multi namespace references operation + * @returns the {@link SavedObjectsCollectMultiNamespaceReferencesResponse} */ collectMultiNamespaceReferences( objects: SavedObjectsCollectMultiNamespaceReferencesObject[], @@ -358,10 +390,11 @@ export interface SavedObjectsClientContract { /** * Updates one or more objects to add and/or remove them from specified spaces. * - * @param objects - * @param spacesToAdd - * @param spacesToRemove - * @param options + * @param objects - array of objects to update (contains ID, type, and optional internal-only parameters) + * @param spacesToAdd - array of spaces each object should be included in + * @param spacesToRemove - array of spaces each object should not be included in + * @param options {@link SavedObjectsUpdateObjectsSpacesOptions} - options for the update spaces operation + * @returns the {@link SavedObjectsUpdateObjectsSpacesResponse} */ updateObjectsSpaces( objects: SavedObjectsUpdateObjectsSpacesObject[], diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/saved_objects_repository.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/saved_objects_repository.ts index d7d2ca57ae3a2..6181cd51a4e69 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/saved_objects_repository.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/saved_objects_repository.ts @@ -49,6 +49,19 @@ import type { SavedObjectsBulkDeleteResponse, } from './apis'; +/** + * @internal + */ +export interface SavedObjectsFindInternalOptions { + /** This is used for calls internal to the SO domain that need to use a PIT finder but want to prevent extensions from functioning. + * We use the SOR's PointInTimeFinder internally when searching for aliases and shared origins for saved objects, but we + * need to disable the extensions for that to function correctly. + * Before, when we had SOC wrappers, the SOR's PointInTimeFinder did not have any of the wrapper functionality applied. + * This disableExtensions internal option preserves that behavior. + */ + disableExtensions?: boolean; +} + /** * The savedObjects repository contract. * @@ -58,15 +71,15 @@ export interface ISavedObjectsRepository { /** * Persists an object * - * @param {string} type - * @param {object} attributes - * @param {object} [options={}] + * @param {string} type - the type of object to create + * @param {object} attributes - the attributes for the object to be created + * @param {object} [options={}] {@link SavedObjectsCreateOptions} - options for the create operation * @property {string} [options.id] - force id on creation, not recommended * @property {boolean} [options.overwrite=false] * @property {object} [options.migrationVersion=undefined] * @property {string} [options.namespace] * @property {array} [options.references=[]] - [{ name, type, id }] - * @returns {promise} - { id, type, version, attributes } + * @returns {promise} the created saved object { id, type, version, attributes } */ create( type: string, @@ -77,11 +90,11 @@ export interface ISavedObjectsRepository { /** * Creates multiple documents at once * - * @param {array} objects - [{ type, id, attributes, references, migrationVersion }] - * @param {object} [options={}] + * @param {array} objects - array of objects to create [{ type, attributes, ... }] + * @param {object} [options={}] {@link SavedObjectsCreateOptions} - options for the bulk create operation * @property {boolean} [options.overwrite=false] - overwrites existing documents * @property {string} [options.namespace] - * @returns {promise} - {saved_objects: [[{ id, type, version, references, attributes, error: { message } }]} + * @returns {promise} - {saved_objects: [[{ id, type, version, references, attributes, error: { message } }]} */ bulkCreate( objects: Array>, @@ -91,6 +104,10 @@ export interface ISavedObjectsRepository { /** * Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are * multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. + * + * @param {array} objects - array of objects to check for conflicts [{ id, type }] + * @param {object} options {@link SavedObjectsBaseOptions} - options for the check conflict operation + * @returns {promise} - {errors: [{ id, type, error: { message } }]} */ checkConflicts( objects: SavedObjectsCheckConflictsObject[], @@ -100,18 +117,17 @@ export interface ISavedObjectsRepository { /** * Deletes an object * - * @param {string} type - * @param {string} id - * @param {object} [options={}] + * @param {string} type - the type of the object to delete + * @param {string} id - the id of the object to delete + * @param {object} [options={}] {@link SavedObjectsDeleteOptions} - options for the delete operation * @property {string} [options.namespace] - * @returns {promise} */ delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; /** * Deletes multiple documents at once - * @param {array} objects - an array of objects containing id and type - * @param {object} [options={}] + * @param {array} objects - an array of objects to delete (contains id and type) + * @param {object} [options={}] {@link SavedObjectsBulkDeleteOptions} - options for the bulk delete operation * @returns {promise} - { statuses: [{ id, type, success, error: { message } }] } */ bulkDelete( @@ -122,7 +138,8 @@ export interface ISavedObjectsRepository { /** * Deletes all objects from the provided namespace. * - * @param {string} namespace + * @param {string} namespace - the namespace in which to delete all objects + * @param {object} options {@link SavedObjectsDeleteByNamespaceOptions} - options for the delete by namespace operation * @returns {promise} - { took, timed_out, total, deleted, batches, version_conflicts, noops, retries, failures } */ deleteByNamespace( @@ -131,7 +148,9 @@ export interface ISavedObjectsRepository { ): Promise; /** - * @param {object} [options={}] + * Find saved objects by query + * + * @param {object} [options={}] {@link SavedObjectsFindOptions} - options for the find operation * @property {(string|Array)} [options.type] * @property {string} [options.search] * @property {string} [options.defaultSearchOperator] @@ -147,17 +166,19 @@ export interface ISavedObjectsRepository { * @property {object} [options.hasReference] - { type, id } * @property {string} [options.pit] * @property {string} [options.preference] + * @param {object} internalOptions {@link SavedObjectsFindInternalOptions} - internal-only options for the find operation * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ find( - options: SavedObjectsFindOptions + options: SavedObjectsFindOptions, + internalOptions?: SavedObjectsFindInternalOptions ): Promise>; /** * Returns an array of objects by id * * @param {array} objects - an array of objects containing id, type and optionally fields - * @param {object} [options={}] + * @param {object} [options={}] {@link SavedObjectsBaseOptions} - options for the bulk get operation * @property {string} [options.namespace] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } * @example @@ -176,7 +197,7 @@ export interface ISavedObjectsRepository { * Resolves an array of objects by id, using any legacy URL aliases if they exist * * @param {array} objects - an array of objects containing id, type - * @param {object} [options={}] + * @param {object} [options={}] {@link SavedObjectsBaseOptions} - options for the bulk resolve operation * @property {string} [options.namespace] * @returns {promise} - { resolved_objects: [{ saved_object, outcome }] } * @example @@ -194,9 +215,9 @@ export interface ISavedObjectsRepository { /** * Gets a single object * - * @param {string} type - * @param {string} id - * @param {object} [options={}] + * @param {string} type - the type of the object to get + * @param {string} id - the ID of the object to get + * @param {object} [options={}] {@link SavedObjectsBaseOptions} - options for the get operation * @property {string} [options.namespace] * @returns {promise} - { id, type, version, attributes } */ @@ -209,9 +230,9 @@ export interface ISavedObjectsRepository { /** * Resolves a single object, using any legacy URL alias if it exists * - * @param {string} type - * @param {string} id - * @param {object} [options={}] + * @param {string} type - the type of the object to resolve + * @param {string} id - the id of the object to resolve + * @param {object} [options={}] {@link SavedObjectsBaseOptions} - options for the resolve operation * @property {string} [options.namespace] * @returns {promise} - { saved_object, outcome } */ @@ -224,13 +245,14 @@ export interface ISavedObjectsRepository { /** * Updates an object * - * @param {string} type - * @param {string} id - * @param {object} [options={}] + * @param {string} type - the type of the object to update + * @param {string} id - the ID of the object to update + * @param {object} attributes - attributes to update + * @param {object} [options={}] {@link SavedObjectsUpdateOptions} - options for the update operation * @property {string} options.version - ensures version matches that of persisted object * @property {string} [options.namespace] * @property {array} [options.references] - [{ name, type, id }] - * @returns {promise} + * @returns {promise} - updated saved object */ update( type: string, @@ -243,7 +265,9 @@ export interface ISavedObjectsRepository { * Gets all references and transitive references of the given objects. Ignores any object and/or reference that is not a multi-namespace * type. * - * @param objects The objects to get the references for. + * @param {array} objects - The objects to get the references for (contains type and ID) + * @param {object} options {@link SavedObjectsCollectMultiNamespaceReferencesOptions} - the options for the operation + * @returns {promise} - {@link SavedObjectsCollectMultiNamespaceReferencesResponse} { objects: [{ type, id, spaces, inboundReferences, ... }] } */ collectMultiNamespaceReferences( objects: SavedObjectsCollectMultiNamespaceReferencesObject[], @@ -253,10 +277,11 @@ export interface ISavedObjectsRepository { /** * Updates one or more objects to add and/or remove them from specified spaces. * - * @param objects - * @param spacesToAdd - * @param spacesToRemove - * @param options + * @param {array} objects - array of objects to update (contains type, ID, and optional parameters) + * @param {array} spacesToAdd - array of spaces in which the objects should be added + * @param {array} spacesToRemove - array of spaces from which the objects should be removed + * @param {object} options {@link SavedObjectsUpdateObjectsSpacesOptions} - options for the operation + * @returns {promise} - { objects: [{ id, type, spaces, error: { message } }] } */ updateObjectsSpaces( objects: SavedObjectsUpdateObjectsSpacesObject[], @@ -268,7 +293,8 @@ export interface ISavedObjectsRepository { /** * Updates multiple objects in bulk * - * @param {array} objects - [{ type, id, attributes, options: { version, namespace } references }] + * @param {array} objects - array of objects to update (contains type, id, attributes, options: { version, namespace } references) + * @param {object} options {@link SavedObjectsBulkUpdateOptions} - options for the bulk update operation * @property {string} options.version - ensures version matches that of persisted object * @property {string} [options.namespace] * @returns {promise} - {saved_objects: [[{ id, type, version, references, attributes, error: { message } }]} @@ -284,6 +310,11 @@ export interface ISavedObjectsRepository { * @remarks Will throw a conflict error if the `update_by_query` operation returns any failure. In that case * some references might have been removed, and some were not. It is the caller's responsibility * to handle and fix this situation if it was to happen. + * + * @param {string} type - the type of the object to remove references to + * @param {string} id - the ID of the object to remove references to + * @param {object} options {@link SavedObjectsRemoveReferencesToOptions} - options for the remove references operation + * @returns {promise} - { number - the number of objects that have been updated by this operation } */ removeReferencesTo( type: string, @@ -338,11 +369,11 @@ export interface ISavedObjectsRepository { * ) * ``` * - * @param type - The type of saved object whose fields should be incremented - * @param id - The id of the document whose fields should be incremented - * @param counterFields - An array of field names to increment or an array of {@link SavedObjectsIncrementCounterField} - * @param options - {@link SavedObjectsIncrementCounterOptions} - * @returns The saved object after the specified fields were incremented + * @param {string} type - The type of saved object whose fields should be incremented + * @param {string} id - The id of the document whose fields should be incremented + * @param {array} counterFields - An array of field names to increment or an array of {@link SavedObjectsIncrementCounterField} + * @param {object} options {@link SavedObjectsIncrementCounterOptions} + * @returns {promise} - The saved object after the specified fields were incremented */ incrementCounter( type: string, @@ -381,15 +412,17 @@ export interface ISavedObjectsRepository { * await savedObjectsClient.closePointInTime(page2.pit_id); * ``` * - * @param {string|Array} type - * @param {object} [options] - {@link SavedObjectsOpenPointInTimeOptions} + * @param {string|Array} type - the type or types for the PIT + * @param {object} [options] {@link SavedObjectsOpenPointInTimeOptions} - options for the open PIT operation * @property {string} [options.keepAlive] * @property {string} [options.preference] - * @returns {promise} - { id: string } + * @param {object} internalOptions {@link SavedObjectsFindInternalOptions} - internal options for the open PIT operation + * @returns {promise} - { id - the ID for the PIT } */ openPointInTimeForType( type: string | string[], - options?: SavedObjectsOpenPointInTimeOptions + options?: SavedObjectsOpenPointInTimeOptions, + internalOptions?: SavedObjectsFindInternalOptions ): Promise; /** @@ -429,13 +462,15 @@ export interface ISavedObjectsRepository { * await repository.closePointInTime(response.pit_id); * ``` * - * @param {string} id - * @param {object} [options] - {@link SavedObjectsClosePointInTimeOptions} - * @returns {promise} - {@link SavedObjectsClosePointInTimeResponse} + * @param {string} id - ID of the saved object + * @param {object} [options] {@link SavedObjectsClosePointInTimeOptions} - options for the close PIT operation + * @param {object} internalOptions {@link SavedObjectsFindInternalOptions} - internal options for the close PIT operation + * @returns {promise} - { succeeded, num_freed - number of contexts closed } */ closePointInTime( id: string, - options?: SavedObjectsClosePointInTimeOptions + options?: SavedObjectsClosePointInTimeOptions, + internalOptions?: SavedObjectsFindInternalOptions ): Promise; /** @@ -464,6 +499,10 @@ export interface ISavedObjectsRepository { * PIT will automatically be closed for you once you reach the last page * of results, or if the underlying call to `find` fails for any reason. * + * @param {object} findOptions - {@link SavedObjectsCreatePointInTimeFinderOptions} - the options for creating the point-in-time finder + * @param {object} dependencies - {@link SavedObjectsCreatePointInTimeFinderDependencies} - the dependencies for creating the point-in-time finder + * @returns - the point-in-time finder {@link ISavedObjectsPointInTimeFinder} + * * @example * ```ts * const findOptions: SavedObjectsCreatePointInTimeFinderOptions = { diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/mappings/lib/get_property.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/mappings/lib/get_property.ts index ac1070741af71..d43a78ec45415 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/mappings/lib/get_property.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/mappings/lib/get_property.ts @@ -8,7 +8,7 @@ import { toPath } from 'lodash'; import type { SavedObjectsFieldMapping } from '@kbn/core-saved-objects-server'; -import { IndexMapping } from '../types'; +import type { IndexMapping } from '../types'; function getPropertyMappingFromObjectMapping( mapping: IndexMapping | SavedObjectsFieldMapping, diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/mappings/lib/get_root_properties_objects.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/mappings/lib/get_root_properties_objects.ts index fb5a7666b9071..83264d7153cc0 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/mappings/lib/get_root_properties_objects.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/mappings/lib/get_root_properties_objects.ts @@ -10,7 +10,7 @@ import type { SavedObjectsFieldMapping, SavedObjectsMappingProperties, } from '@kbn/core-saved-objects-server'; -import { IndexMapping } from '../types'; +import type { IndexMapping } from '../types'; import { getRootProperties } from './get_root_properties'; /** diff --git a/packages/core/saved-objects/core-saved-objects-common/src/saved_objects.ts b/packages/core/saved-objects/core-saved-objects-common/src/saved_objects.ts index f98c39871353f..b9ea336d349bd 100644 --- a/packages/core/saved-objects/core-saved-objects-common/src/saved_objects.ts +++ b/packages/core/saved-objects/core-saved-objects-common/src/saved_objects.ts @@ -64,6 +64,7 @@ export interface SavedObjectReference { * @public */ export interface SavedObjectsMigrationVersion { + /** The plugin name and version string */ [pluginName: string]: string; } @@ -78,6 +79,7 @@ export interface SavedObject { created_at?: string; /** Timestamp of the last time this document had been updated. */ updated_at?: string; + /** Error associated with this object, populated if an operation failed for this object. */ error?: SavedObjectError; /** The data for a Saved Object is stored as an object in the `attributes` property. **/ attributes: T; diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/apply_export_transforms.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/apply_export_transforms.ts index 0c871327136f9..4a7b98f5111d0 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/apply_export_transforms.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/apply_export_transforms.ts @@ -13,7 +13,7 @@ import type { SavedObjectsExportTransformContext, } from '@kbn/core-saved-objects-server'; import { SavedObjectsExportError } from './errors'; -import { getObjKey, SavedObjectComparator } from './utils'; +import { getObjKey, type SavedObjectComparator } from './utils'; interface ApplyExportTransformsOptions { objects: SavedObject[]; diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/collect_exported_objects.test.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/collect_exported_objects.test.ts index 04ea3f984b19b..6125c9fb7d739 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/collect_exported_objects.test.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/collect_exported_objects.test.ts @@ -16,7 +16,7 @@ import { applyExportTransformsMock } from './collect_exported_objects.test.mocks import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal'; -import { collectExportedObjects, ExclusionReason } from './collect_exported_objects'; +import { collectExportedObjects, type ExclusionReason } from './collect_exported_objects'; const createObject = (parts: Partial): SavedObject => ({ id: 'id', diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/saved_objects_exporter.test.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/saved_objects_exporter.test.ts index fed06cbf2f740..498ddde9fbbae 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/saved_objects_exporter.test.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/saved_objects_exporter.test.ts @@ -11,7 +11,7 @@ import type { SavedObject } from '@kbn/core-saved-objects-common'; import { SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal'; import { SavedObjectsExporter } from './saved_objects_exporter'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; -import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { loggerMock, type MockedLogger } from '@kbn/logging-mocks'; import { Readable } from 'stream'; import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; @@ -127,6 +127,7 @@ describe('getSortedObjectsForExport()', () => { "search", ], }, + undefined, ], ], "results": Array [ @@ -242,7 +243,8 @@ describe('getSortedObjectsForExport()', () => { sortField: 'updated_at', sortOrder: 'desc', type: ['index-pattern'], - }) + }), + undefined // PointInTimeFinder adds `internalOptions`, which is undefined in this case ); }); }); @@ -480,6 +482,7 @@ describe('getSortedObjectsForExport()', () => { "search", ], }, + undefined, ], ], "results": Array [ @@ -639,6 +642,7 @@ describe('getSortedObjectsForExport()', () => { "search", ], }, + undefined, ], ], "results": Array [ @@ -735,6 +739,7 @@ describe('getSortedObjectsForExport()', () => { "search", ], }, + undefined, ], ], "results": Array [ @@ -836,6 +841,7 @@ describe('getSortedObjectsForExport()', () => { "search", ], }, + undefined, ], ], "results": Array [ diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/saved_objects_exporter.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/saved_objects_exporter.ts index c3213eb9108bd..bd122dbf8b392 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/saved_objects_exporter.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/export/saved_objects_exporter.ts @@ -25,7 +25,11 @@ import type { import { sortObjects } from './sort_objects'; import { SavedObjectsExportError } from './errors'; import { collectExportedObjects } from './collect_exported_objects'; -import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils'; +import { + byIdAscComparator, + getPreservedOrderComparator, + type SavedObjectComparator, +} from './utils'; /** * @internal diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.test.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.test.ts index b44020e1774be..02af9ca6dca77 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.test.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/import_saved_objects.test.ts @@ -32,7 +32,10 @@ import type { } from '@kbn/core-saved-objects-server'; import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; -import { importSavedObjectsFromStream, ImportSavedObjectsOptions } from './import_saved_objects'; +import { + importSavedObjectsFromStream, + type ImportSavedObjectsOptions, +} from './import_saved_objects'; import type { ImportStateMap } from './lib'; describe('#importSavedObjectsFromStream', () => { diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/lib/check_reference_origins.test.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/lib/check_reference_origins.test.ts index 37a9aa96dd52f..3925ed932aa11 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/lib/check_reference_origins.test.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/lib/check_reference_origins.test.ts @@ -13,7 +13,7 @@ import type { SavedObjectsClientContract, } from '@kbn/core-saved-objects-api-server'; import type { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server'; -import { checkReferenceOrigins, CheckReferenceOriginsParams } from './check_reference_origins'; +import { checkReferenceOrigins, type CheckReferenceOriginsParams } from './check_reference_origins'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; import type { ImportStateMap } from './types'; diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/lib/create_saved_objects.test.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/lib/create_saved_objects.test.ts index 907532bcc8fbb..146e6650fa4c4 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/lib/create_saved_objects.test.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/lib/create_saved_objects.test.ts @@ -8,7 +8,7 @@ import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import type { SavedObject, SavedObjectsImportFailure } from '@kbn/core-saved-objects-common'; -import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server'; import { createSavedObjects } from './create_saved_objects'; import { extractErrors } from './extract_errors'; diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/resolve_import_errors.test.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/resolve_import_errors.test.ts index ed93301a45813..0bc8a9190202e 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/resolve_import_errors.test.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/resolve_import_errors.test.ts @@ -40,7 +40,7 @@ import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import { resolveSavedObjectsImportErrors, - ResolveSavedObjectsImportErrorsOptions, + type ResolveSavedObjectsImportErrorsOptions, } from './resolve_import_errors'; describe('#importSavedObjectsFromStream', () => { diff --git a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/resolve_import_errors.ts b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/resolve_import_errors.ts index 9d96126265fd1..72533ba7c877b 100644 --- a/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/resolve_import_errors.ts +++ b/packages/core/saved-objects/core-saved-objects-import-export-server-internal/src/import/resolve_import_errors.ts @@ -32,7 +32,7 @@ import { checkConflicts, executeImportHooks, checkOriginConflicts, - ImportStateMap, + type ImportStateMap, } from './lib'; /** diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/index.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/index.ts index cedd5dc369826..92a5fb380dc19 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/index.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/index.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -export { KibanaMigrator, buildActiveMappings, mergeTypes } from './src'; +export { DocumentMigrator, KibanaMigrator, buildActiveMappings, mergeTypes } from './src'; export type { KibanaMigratorOptions } from './src'; export { getAggregatedTypesDocuments } from './src/actions/check_for_unknown_docs'; export { addExcludedTypesToBoolQuery } from './src/model/helpers'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/bulk_overwrite_transformed_documents.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/bulk_overwrite_transformed_documents.ts index 39194625e4531..7a6e8b2d9a5b5 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/bulk_overwrite_transformed_documents.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/bulk_overwrite_transformed_documents.ts @@ -14,7 +14,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { SavedObjectsRawDoc } from '@kbn/core-saved-objects-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; import { isWriteBlockException, isIndexNotFoundException } from './es_errors'; import { WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE } from './constants'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/clone_index.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/clone_index.ts index 0e07a68c1ec39..41830a4af078e 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/clone_index.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/clone_index.ts @@ -13,7 +13,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; import type { IndexNotFound, AcknowledgeResponse } from '.'; import { type IndexNotGreenTimeout, waitForIndexStatus } from './wait_for_index_status'; @@ -24,7 +24,7 @@ import { WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE, } from './constants'; import { isClusterShardLimitExceeded } from './es_errors'; -import { ClusterShardLimitExceeded } from './create_index'; +import type { ClusterShardLimitExceeded } from './create_index'; export type CloneIndexResponse = AcknowledgeResponse; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/close_pit.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/close_pit.ts index a3ea50edccae6..adc2658e00bdf 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/close_pit.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/close_pit.ts @@ -11,7 +11,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; /** @internal */ diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/create_index.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/create_index.ts index 9353d28e9ffd8..0e68b0cef14c5 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/create_index.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/create_index.ts @@ -12,10 +12,10 @@ import { pipe } from 'fp-ts/lib/pipeable'; import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { IndexMapping } from '@kbn/core-saved-objects-base-server-internal'; -import { AcknowledgeResponse } from '.'; +import type { AcknowledgeResponse } from '.'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; import { DEFAULT_TIMEOUT, diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/fetch_indices.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/fetch_indices.ts index 0eb43380a6990..a9be3299978e0 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/fetch_indices.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/fetch_indices.ts @@ -12,7 +12,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { IndexMapping } from '@kbn/core-saved-objects-base-server-internal'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; export type FetchIndexResponse = Record< diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/index.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/index.ts index 2b6d501f787b0..d2eeaf6c547d5 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/index.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/index.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { RetryableEsClientError } from './catch_retryable_es_client_errors'; -import { DocumentsTransformFailed } from '../core/migrate_raw_docs'; +import type { RetryableEsClientError } from './catch_retryable_es_client_errors'; +import type { DocumentsTransformFailed } from '../core/migrate_raw_docs'; export { BATCH_SIZE, diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/initialize_action.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/initialize_action.ts index a1b5e01360018..8daa548039eeb 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/initialize_action.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/initialize_action.ts @@ -12,10 +12,10 @@ import { pipe } from 'fp-ts/lib/pipeable'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; -import { FetchIndexResponse, fetchIndices } from './fetch_indices'; +import { type FetchIndexResponse, fetchIndices } from './fetch_indices'; const routingAllocationEnable = 'cluster.routing.allocation.enable'; export interface ClusterRoutingAllocationEnabled { diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/open_pit.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/open_pit.ts index 3966198393c21..c2573c7886344 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/open_pit.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/open_pit.ts @@ -11,7 +11,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; /** @internal */ diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/pickup_updated_mappings.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/pickup_updated_mappings.ts index 9cb37facc0b5a..0e34857b4f208 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/pickup_updated_mappings.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/pickup_updated_mappings.ts @@ -11,7 +11,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; import { BATCH_SIZE } from './constants'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/read_with_pit.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/read_with_pit.ts index 19e9ed2072c2b..1d0303947c1b6 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/read_with_pit.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/read_with_pit.ts @@ -13,7 +13,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { SavedObjectsRawDoc } from '@kbn/core-saved-objects-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; import { DEFAULT_PIT_KEEP_ALIVE } from './open_pit'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/refresh_index.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/refresh_index.ts index 7eb5c5d560d4c..64940e6e81f5c 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/refresh_index.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/refresh_index.ts @@ -11,7 +11,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; /** @internal */ diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/remove_write_block.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/remove_write_block.ts index 35a4dff315277..86896fa0d75fe 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/remove_write_block.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/remove_write_block.ts @@ -11,7 +11,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; import { DEFAULT_TIMEOUT } from './constants'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/search_for_outdated_documents.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/search_for_outdated_documents.ts index ac5508a140038..2ea1882d4e35f 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/search_for_outdated_documents.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/search_for_outdated_documents.ts @@ -13,7 +13,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '@kbn/core-saved-objects-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; /** @internal */ diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/set_write_block.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/set_write_block.ts index a84f976ceb643..38c2d73b21470 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/set_write_block.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/set_write_block.ts @@ -12,7 +12,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; import { DEFAULT_TIMEOUT, IndexNotFound } from '.'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/transform_docs.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/transform_docs.ts index 52e79e6ac88c2..682a73de28788 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/transform_docs.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/transform_docs.ts @@ -9,7 +9,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither'; import type { SavedObjectsRawDoc } from '@kbn/core-saved-objects-server'; import type { TransformRawDocs } from '../types'; -import { DocumentsTransformFailed, DocumentsTransformSuccess } from '../core/migrate_raw_docs'; +import type { DocumentsTransformFailed, DocumentsTransformSuccess } from '../core/migrate_raw_docs'; /** @internal */ export interface TransformDocsParams { diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/update_aliases.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/update_aliases.ts index c896dbf27258f..eeaadbb86306f 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/update_aliases.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/update_aliases.ts @@ -12,7 +12,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; import { DEFAULT_TIMEOUT, IndexNotFound } from '.'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/update_and_pickup_mappings.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/update_and_pickup_mappings.ts index 36a00f1096b1d..653a90746dea0 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/update_and_pickup_mappings.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/update_and_pickup_mappings.ts @@ -13,7 +13,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { IndexMapping } from '@kbn/core-saved-objects-base-server-internal'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; import { pickupUpdatedMappings } from './pickup_updated_mappings'; import { DEFAULT_TIMEOUT } from './constants'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_index_status.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_index_status.ts index 7ee63e7583851..25514ea282797 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_index_status.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_index_status.ts @@ -11,7 +11,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; import { DEFAULT_TIMEOUT } from './constants'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_reindex_task.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_reindex_task.ts index 3eb7b0da718c2..9d764ae5286e1 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_reindex_task.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_reindex_task.ts @@ -9,9 +9,9 @@ import * as TaskEither from 'fp-ts/lib/TaskEither'; import * as Option from 'fp-ts/lib/Option'; import { flow } from 'fp-ts/lib/function'; -import { RetryableEsClientError } from './catch_retryable_es_client_errors'; +import type { RetryableEsClientError } from './catch_retryable_es_client_errors'; import type { IndexNotFound, TargetIndexHadWriteBlock } from '.'; -import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task'; +import { waitForTask, type WaitForTaskCompletionTimeout } from './wait_for_task'; import { isWriteBlockException, isIncompatibleMappingException } from './es_errors'; export interface IncompatibleMappingException { diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_task.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_task.ts index a5762ff10a122..f0556e6b0d8a8 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_task.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/actions/wait_for_task.ts @@ -13,7 +13,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { catchRetryableEsClientErrors, - RetryableEsClientError, + type RetryableEsClientError, } from './catch_retryable_es_client_errors'; /** @internal */ diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/index.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/index.ts index 817a284468c1f..7f520531f22e6 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/index.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/index.ts @@ -9,3 +9,4 @@ export { KibanaMigrator, mergeTypes } from './kibana_migrator'; export type { KibanaMigratorOptions } from './kibana_migrator'; export { buildActiveMappings } from './core'; +export { DocumentMigrator } from './core'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.test.ts index dc5addb1624b8..4e8dcdd7c7f54 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.test.ts @@ -13,7 +13,7 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import type { SavedObjectsType } from '@kbn/core-saved-objects-server'; import { SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal'; -import { KibanaMigratorOptions, KibanaMigrator } from './kibana_migrator'; +import { type KibanaMigratorOptions, KibanaMigrator } from './kibana_migrator'; import { DocumentMigrator } from './core/document_migrator'; import { ByteSizeValue } from '@kbn/config-schema'; import { docLinksServiceMock } from '@kbn/core-doc-links-server-mocks'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.ts index 837c2a47bea58..3432021249886 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/kibana_migrator.ts @@ -32,7 +32,7 @@ import { type MigrationResult, } from '@kbn/core-saved-objects-base-server-internal'; import { buildActiveMappings } from './core'; -import { DocumentMigrator, VersionedTransformer } from './core/document_migrator'; +import { DocumentMigrator, type VersionedTransformer } from './core/document_migrator'; import { createIndexMap } from './core/build_index_map'; import { runResilientMigrator } from './run_resilient_migrator'; import { migrateRawDocsSafely } from './core/migrate_raw_docs'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/migrations_state_action_machine.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/migrations_state_action_machine.ts index f21651d82f8f9..ef9db961c8112 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/migrations_state_action_machine.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/migrations_state_action_machine.ts @@ -15,9 +15,9 @@ import { getRequestDebugMeta, } from '@kbn/core-elasticsearch-client-server-internal'; import type { SavedObjectsRawDoc } from '@kbn/core-saved-objects-server'; -import { Model, Next, stateActionMachine } from './state_action_machine'; +import { type Model, type Next, stateActionMachine } from './state_action_machine'; import { cleanup } from './migrations_state_machine_cleanup'; -import { ReindexSourceToTempTransform, ReindexSourceToTempIndexBulk, State } from './state'; +import type { ReindexSourceToTempTransform, ReindexSourceToTempIndexBulk, State } from './state'; interface StateTransitionLogMeta extends LogMeta { kibana: { diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.test.ts index cb446b952e5ec..dd7e36f011373 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/model/model.test.ts @@ -42,9 +42,9 @@ import type { CheckUnknownDocumentsState, CalculateExcludeFiltersState, } from '../state'; -import { TransformErrorObjects, TransformSavedObjectDocumentError } from '../core'; -import { AliasAction, RetryableEsClientError } from '../actions'; -import { ResponseType } from '../next'; +import { type TransformErrorObjects, TransformSavedObjectDocumentError } from '../core'; +import type { AliasAction, RetryableEsClientError } from '../actions'; +import type { ResponseType } from '../next'; import { createInitialProgress } from './progress'; import { model } from './model'; diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/next.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/next.test.ts index 548c6b8c43b51..cc4ee13673940 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/next.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/next.test.ts @@ -8,7 +8,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { next } from './next'; -import { State } from './state'; +import type { State } from './state'; describe('migrations v2 next', () => { it.todo('when state.retryDelay > 0 delays execution of the next action'); diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/types.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/types.ts index cf7c74c305f2b..13aa26a97f1df 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/types.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/types.ts @@ -8,7 +8,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither'; import type { SavedObjectsRawDoc } from '@kbn/core-saved-objects-server'; -import { DocumentsTransformFailed, DocumentsTransformSuccess } from './core'; +import type { DocumentsTransformFailed, DocumentsTransformSuccess } from './core'; /** @internal */ export type TransformRawDocs = ( diff --git a/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.test.ts b/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.test.ts index 61978d3915a1a..6b58566f4cc14 100644 --- a/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.test.ts +++ b/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.test.ts @@ -18,7 +18,7 @@ import { typeRegistryInstanceMock, } from './saved_objects_service.test.mocks'; import { BehaviorSubject } from 'rxjs'; -import { RawPackageInfo, Env } from '@kbn/config'; +import { type RawPackageInfo, Env } from '@kbn/config'; import { ByteSizeValue } from '@kbn/config-schema'; import { REPO_ROOT } from '@kbn/utils'; import { getEnvOptions } from '@kbn/config-mocks'; @@ -26,10 +26,18 @@ import { docLinksServiceMock } from '@kbn/core-doc-links-server-mocks'; import { nodeServiceMock } from '@kbn/core-node-server-mocks'; import { mockCoreContext } from '@kbn/core-base-server-mocks'; import { httpServiceMock, httpServerMock } from '@kbn/core-http-server-mocks'; -import type { SavedObjectsClientFactoryProvider } from '@kbn/core-saved-objects-server'; +import { + SavedObjectsClientFactoryProvider, + SavedObjectsEncryptionExtensionFactory, + SavedObjectsSecurityExtensionFactory, + SavedObjectsSpacesExtensionFactory, +} from '@kbn/core-saved-objects-server'; import { configServiceMock } from '@kbn/config-mocks'; import type { NodesVersionCompatibility } from '@kbn/core-elasticsearch-server-internal'; -import { SavedObjectsRepository } from '@kbn/core-saved-objects-api-server-internal'; +import { + SavedObjectsClientProvider, + SavedObjectsRepository, +} from '@kbn/core-saved-objects-api-server-internal'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; import { SavedObjectsService } from './saved_objects_service'; @@ -192,30 +200,121 @@ describe('SavedObjectsService', () => { }); }); - describe('#addClientWrapper', () => { - it('registers the wrapper to the clientProvider', async () => { + describe('#extensions', () => { + it('registers the encryption extension to the clientProvider', async () => { const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const setup = await soService.setup(createSetupDeps()); + const encryptionExtension: jest.Mocked = jest.fn(); + setup.setEncryptionExtension(encryptionExtension); + + await soService.start(createStartDeps()); - const wrapperA = jest.fn(); - const wrapperB = jest.fn(); + expect(SavedObjectsClientProvider).toHaveBeenCalledTimes(1); + expect(SavedObjectsClientProvider).toHaveBeenCalledWith( + expect.objectContaining({ + encryptionExtensionFactory: encryptionExtension, + securityExtensionFactory: undefined, + spacesExtensionFactory: undefined, + }) + ); + }); - setup.addClientWrapper(1, 'A', wrapperA); - setup.addClientWrapper(2, 'B', wrapperB); + it('registers the security extension to the clientProvider', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); + const securityExtension: jest.Mocked = jest.fn(); + setup.setSecurityExtension(securityExtension); await soService.start(createStartDeps()); - expect(clientProviderInstanceMock.addClientWrapperFactory).toHaveBeenCalledTimes(2); - expect(clientProviderInstanceMock.addClientWrapperFactory).toHaveBeenCalledWith( - 1, - 'A', - wrapperA + expect(SavedObjectsClientProvider).toHaveBeenCalledTimes(1); + expect(SavedObjectsClientProvider).toHaveBeenCalledWith( + expect.objectContaining({ + encryptionExtensionFactory: undefined, + securityExtensionFactory: securityExtension, + spacesExtensionFactory: undefined, + }) ); - expect(clientProviderInstanceMock.addClientWrapperFactory).toHaveBeenCalledWith( - 2, - 'B', - wrapperB + }); + + it('registers the spaces extension to the clientProvider', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); + const spacesExtension: jest.Mocked = jest.fn(); + setup.setSpacesExtension(spacesExtension); + + await soService.start(createStartDeps()); + + expect(SavedObjectsClientProvider).toHaveBeenCalledTimes(1); + expect(SavedObjectsClientProvider).toHaveBeenCalledWith( + expect.objectContaining({ + encryptionExtensionFactory: undefined, + securityExtensionFactory: undefined, + spacesExtensionFactory: spacesExtension, + }) + ); + }); + + it('registers a combination of extensions to the clientProvider', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); + const encryptionExtension: jest.Mocked = jest.fn(); + const spacesExtension: jest.Mocked = jest.fn(); + setup.setEncryptionExtension(encryptionExtension); + setup.setSpacesExtension(spacesExtension); + + await soService.start(createStartDeps()); + + expect(SavedObjectsClientProvider).toHaveBeenCalledTimes(1); + expect(SavedObjectsClientProvider).toHaveBeenCalledWith( + expect.objectContaining({ + encryptionExtensionFactory: encryptionExtension, + securityExtensionFactory: undefined, + spacesExtensionFactory: spacesExtension, + }) + ); + }); + + it('registers all three extensions to the clientProvider', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + const setup = await soService.setup(createSetupDeps()); + const encryptionExtension: jest.Mocked = jest.fn(); + const securityExtension: jest.Mocked = jest.fn(); + const spacesExtension: jest.Mocked = jest.fn(); + setup.setEncryptionExtension(encryptionExtension); + setup.setSecurityExtension(securityExtension); + setup.setSpacesExtension(spacesExtension); + + await soService.start(createStartDeps()); + + expect(SavedObjectsClientProvider).toHaveBeenCalledTimes(1); + expect(SavedObjectsClientProvider).toHaveBeenCalledWith( + expect.objectContaining({ + encryptionExtensionFactory: encryptionExtension, + securityExtensionFactory: securityExtension, + spacesExtensionFactory: spacesExtension, + }) + ); + }); + + it('registers no extensions to the clientProvider', async () => { + const coreContext = createCoreContext(); + const soService = new SavedObjectsService(coreContext); + await soService.setup(createSetupDeps()); + await soService.start(createStartDeps()); + + expect(SavedObjectsClientProvider).toHaveBeenCalledTimes(1); + expect(SavedObjectsClientProvider).toHaveBeenCalledWith( + expect.objectContaining({ + encryptionExtensionFactory: undefined, + securityExtensionFactory: undefined, + spacesExtensionFactory: undefined, + }) ); }); }); @@ -414,12 +513,6 @@ describe('SavedObjectsService', () => { `"cannot call \`setClientFactoryProvider\` after service startup."` ); - expect(() => { - setup.addClientWrapper(0, 'dummy', jest.fn()); - }).toThrowErrorMatchingInlineSnapshot( - `"cannot call \`addClientWrapper\` after service startup."` - ); - expect(() => { setup.registerType({ name: 'someType', diff --git a/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.ts b/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.ts index 9f052ff61614c..6915d5f474b77 100644 --- a/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.ts +++ b/packages/core/saved-objects/core-saved-objects-server-internal/src/saved_objects_service.ts @@ -25,8 +25,11 @@ import type { SavedObjectsRepositoryFactory, SavedObjectStatusMeta, SavedObjectsClientFactoryProvider, - SavedObjectsClientWrapperFactory, ISavedObjectTypeRegistry, + SavedObjectsEncryptionExtensionFactory, + SavedObjectsSecurityExtensionFactory, + SavedObjectsSpacesExtensionFactory, + SavedObjectsExtensions, } from '@kbn/core-saved-objects-server'; import { SavedObjectConfig, @@ -75,12 +78,6 @@ export interface SavedObjectsSetupDeps { deprecations: DeprecationRegistryProvider; } -interface WrappedClientFactoryWrapper { - priority: number; - id: string; - factory: SavedObjectsClientWrapperFactory; -} - /** @internal */ export interface SavedObjectsStartDeps { elasticsearch: InternalElasticsearchServiceStart; @@ -98,7 +95,9 @@ export class SavedObjectsService private setupDeps?: SavedObjectsSetupDeps; private config?: SavedObjectConfig; private clientFactoryProvider?: SavedObjectsClientFactoryProvider; - private clientFactoryWrappers: WrappedClientFactoryWrapper[] = []; + private encryptionExtensionFactory?: SavedObjectsEncryptionExtensionFactory; + private securityExtensionFactory?: SavedObjectsSecurityExtensionFactory; + private spacesExtensionFactory?: SavedObjectsSpacesExtensionFactory; private migrator$ = new Subject(); private typeRegistry = new SavedObjectTypeRegistry(); @@ -162,15 +161,32 @@ export class SavedObjectsService } this.clientFactoryProvider = provider; }, - addClientWrapper: (priority, id, factory) => { + setEncryptionExtension: (factory) => { if (this.started) { - throw new Error('cannot call `addClientWrapper` after service startup.'); + throw new Error('cannot call `setEncryptionExtension` after service startup.'); + } + if (this.encryptionExtensionFactory) { + throw new Error('encryption extension is already set, and can only be set once'); + } + this.encryptionExtensionFactory = factory; + }, + setSecurityExtension: (factory) => { + if (this.started) { + throw new Error('cannot call `setSecurityExtension` after service startup.'); + } + if (this.securityExtensionFactory) { + throw new Error('security extension is already set, and can only be set once'); } - this.clientFactoryWrappers.push({ - priority, - id, - factory, - }); + this.securityExtensionFactory = factory; + }, + setSpacesExtension: (factory) => { + if (this.started) { + throw new Error('cannot call `setSpacesExtension` after service startup.'); + } + if (this.spacesExtensionFactory) { + throw new Error('spaces extension is already set, and can only be set once'); + } + this.spacesExtensionFactory = factory; }, registerType: (type) => { if (this.started) { @@ -255,7 +271,8 @@ export class SavedObjectsService const createRepository = ( esClient: ElasticsearchClient, - includedHiddenTypes: string[] = [] + includedHiddenTypes: string[] = [], + extensions?: SavedObjectsExtensions ) => { return SavedObjectsRepository.createRepository( migrator, @@ -263,31 +280,42 @@ export class SavedObjectsService kibanaIndex, esClient, this.logger.get('repository'), - includedHiddenTypes + includedHiddenTypes, + extensions ); }; const repositoryFactory: SavedObjectsRepositoryFactory = { - createInternalRepository: (includedHiddenTypes?: string[]) => - createRepository(client.asInternalUser, includedHiddenTypes), - createScopedRepository: (req: KibanaRequest, includedHiddenTypes?: string[]) => - createRepository(client.asScoped(req).asCurrentUser, includedHiddenTypes), + createInternalRepository: ( + includedHiddenTypes?: string[], + extensions?: SavedObjectsExtensions | undefined + ) => createRepository(client.asInternalUser, includedHiddenTypes, extensions), + createScopedRepository: ( + req: KibanaRequest, + includedHiddenTypes?: string[], + extensions?: SavedObjectsExtensions + ) => createRepository(client.asScoped(req).asCurrentUser, includedHiddenTypes, extensions), }; const clientProvider = new SavedObjectsClientProvider({ - defaultClientFactory({ request, includedHiddenTypes }) { - const repository = repositoryFactory.createScopedRepository(request, includedHiddenTypes); + defaultClientFactory({ request, includedHiddenTypes, extensions }): SavedObjectsClient { + const repository = repositoryFactory.createScopedRepository( + request, + includedHiddenTypes, + extensions + ); return new SavedObjectsClient(repository); }, typeRegistry: this.typeRegistry, + encryptionExtensionFactory: this.encryptionExtensionFactory, + securityExtensionFactory: this.securityExtensionFactory, + spacesExtensionFactory: this.spacesExtensionFactory, }); + if (this.clientFactoryProvider) { const clientFactory = this.clientFactoryProvider(repositoryFactory); clientProvider.setClientFactory(clientFactory); } - this.clientFactoryWrappers.forEach(({ id, factory, priority }) => { - clientProvider.addClientWrapperFactory(priority, id, factory); - }); this.started = true; diff --git a/packages/core/saved-objects/core-saved-objects-server-mocks/src/saved_objects_service.mock.ts b/packages/core/saved-objects/core-saved-objects-server-mocks/src/saved_objects_service.mock.ts index 9939e5d3240e9..eccdb9ca16c54 100644 --- a/packages/core/saved-objects/core-saved-objects-server-mocks/src/saved_objects_service.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-server-mocks/src/saved_objects_service.mock.ts @@ -63,7 +63,9 @@ const createInternalStartContractMock = (typeRegistry?: jest.Mocked { const setupContract: jest.Mocked = { setClientFactoryProvider: jest.fn(), - addClientWrapper: jest.fn(), + setEncryptionExtension: jest.fn(), + setSecurityExtension: jest.fn(), + setSpacesExtension: jest.fn(), registerType: jest.fn(), getKibanaIndex: jest.fn(), }; diff --git a/packages/core/saved-objects/core-saved-objects-server/index.ts b/packages/core/saved-objects/core-saved-objects-server/index.ts index 38ee45b14c019..c96d40ca7a72f 100644 --- a/packages/core/saved-objects/core-saved-objects-server/index.ts +++ b/packages/core/saved-objects/core-saved-objects-server/index.ts @@ -9,10 +9,12 @@ export type { SavedObjectsClientFactory, SavedObjectsClientFactoryProvider, - SavedObjectsClientWrapperFactory, SavedObjectsRepositoryFactory, SavedObjectsClientProviderOptions, - SavedObjectsClientWrapperOptions, + SavedObjectsEncryptionExtensionFactory, + SavedObjectsSecurityExtensionFactory, + SavedObjectsSpacesExtensionFactory, + SavedObjectsExtensionFactory, } from './src/client_factory'; export type { SavedObjectsServiceSetup, SavedObjectsServiceStart } from './src/contracts'; export type { @@ -64,3 +66,25 @@ export type { } from './src/serialization'; export type { ISavedObjectTypeRegistry } from './src/type_registry'; export type { SavedObjectsValidationMap, SavedObjectsValidationSpec } from './src/validation'; +export type { + ISavedObjectsEncryptionExtension, + EncryptedObjectDescriptor, +} from './src/extensions/encryption'; +export type { + CheckAuthorizationParams, + AuthorizationTypeEntry, + AuthorizationTypeMap, + CheckAuthorizationResult, + EnforceAuthorizationParams, + AddAuditEventParams, + RedactNamespacesParams, + ISavedObjectsSecurityExtension, +} from './src/extensions/security'; +export { AuditAction } from './src/extensions/security'; +export type { ISavedObjectsSpacesExtension } from './src/extensions/spaces'; +export type { SavedObjectsExtensions } from './src/extensions/extensions'; +export { + ENCRYPTION_EXTENSION_ID, + SECURITY_EXTENSION_ID, + SPACES_EXTENSION_ID, +} from './src/extensions/extensions'; diff --git a/packages/core/saved-objects/core-saved-objects-server/src/client_factory.ts b/packages/core/saved-objects/core-saved-objects-server/src/client_factory.ts index ae0e3a649ada9..c77d5b6f60327 100644 --- a/packages/core/saved-objects/core-saved-objects-server/src/client_factory.ts +++ b/packages/core/saved-objects/core-saved-objects-server/src/client_factory.ts @@ -11,37 +11,58 @@ import type { SavedObjectsClientContract, ISavedObjectsRepository, } from '@kbn/core-saved-objects-api-server'; +import type { ISavedObjectsEncryptionExtension } from './extensions/encryption'; +import type { SavedObjectsExtensions } from './extensions/extensions'; +import type { ISavedObjectsSecurityExtension } from './extensions/security'; +import type { ISavedObjectsSpacesExtension } from './extensions/spaces'; import type { ISavedObjectTypeRegistry } from './type_registry'; /** - * Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. + * Describes the factory used to create instances of the Saved Objects Client. * @public */ -export interface SavedObjectsClientWrapperOptions { - client: SavedObjectsClientContract; +export type SavedObjectsClientFactory = ({ + request, + includedHiddenTypes, + extensions, +}: { + request: KibanaRequest; + includedHiddenTypes?: string[]; + extensions?: SavedObjectsExtensions; +}) => SavedObjectsClientContract; + +/** + * Describes the base Saved Objects Extension factory. + * @public + */ +export type SavedObjectsExtensionFactory = (params: { typeRegistry: ISavedObjectTypeRegistry; request: KibanaRequest; -} +}) => T; /** - * Describes the factory used to create instances of Saved Objects Client Wrappers. + * Describes the factory used to create instances of the Saved Objects Encryption Extension. * @public */ -export type SavedObjectsClientWrapperFactory = ( - options: SavedObjectsClientWrapperOptions -) => SavedObjectsClientContract; +export type SavedObjectsEncryptionExtensionFactory = SavedObjectsExtensionFactory< + ISavedObjectsEncryptionExtension | undefined +>; /** - * Describes the factory used to create instances of the Saved Objects Client. + * Describes the factory used to create instances of the Saved Objects Security Extension. * @public */ -export type SavedObjectsClientFactory = ({ - request, - includedHiddenTypes, -}: { - request: KibanaRequest; - includedHiddenTypes?: string[]; -}) => SavedObjectsClientContract; +export type SavedObjectsSecurityExtensionFactory = SavedObjectsExtensionFactory< + ISavedObjectsSecurityExtension | undefined +>; // May be undefined if RBAC is disabled + +/** + * Describes the factory used to create instances of the Saved Objects Spaces Extension. + * @public + */ +export type SavedObjectsSpacesExtensionFactory = SavedObjectsExtensionFactory< + ISavedObjectsSpacesExtension | undefined +>; /** * Provider to invoke to retrieve a {@link SavedObjectsClientFactory}. @@ -56,8 +77,10 @@ export type SavedObjectsClientFactoryProvider = ( * @public */ export interface SavedObjectsClientProviderOptions { - excludedWrappers?: string[]; + /** Array of hidden types to include */ includedHiddenTypes?: string[]; + /** array of extensions to exclude (ENCRYPTION_EXTENSION_ID | SECURITY_EXTENSION_ID | SPACES_EXTENSION_ID) */ + excludedExtensions?: string[]; } /** @@ -73,16 +96,22 @@ export interface SavedObjectsRepositoryFactory { * Elasticsearch. * * @param includedHiddenTypes - A list of additional hidden types the repository should have access to. + * @param extensions - Extensions that the repository should use (for encryption, security, and spaces). */ createScopedRepository: ( req: KibanaRequest, - includedHiddenTypes?: string[] + includedHiddenTypes?: string[], + extensions?: SavedObjectsExtensions ) => ISavedObjectsRepository; /** * Creates a {@link ISavedObjectsRepository | Saved Objects repository} that * uses the internal Kibana user for authenticating with Elasticsearch. * * @param includedHiddenTypes - A list of additional hidden types the repository should have access to. + * @param extensions - Extensions that the repository should use (for encryption, security, and spaces). */ - createInternalRepository: (includedHiddenTypes?: string[]) => ISavedObjectsRepository; + createInternalRepository: ( + includedHiddenTypes?: string[], + extensions?: SavedObjectsExtensions + ) => ISavedObjectsRepository; } diff --git a/packages/core/saved-objects/core-saved-objects-server/src/contracts.ts b/packages/core/saved-objects/core-saved-objects-server/src/contracts.ts index 20821cef46e7a..6c4dcbc3f6b40 100644 --- a/packages/core/saved-objects/core-saved-objects-server/src/contracts.ts +++ b/packages/core/saved-objects/core-saved-objects-server/src/contracts.ts @@ -14,23 +14,25 @@ import type { import type { ISavedObjectsSerializer } from './serialization'; import type { SavedObjectsClientFactoryProvider, - SavedObjectsClientWrapperFactory, SavedObjectsClientProviderOptions, + SavedObjectsEncryptionExtensionFactory, + SavedObjectsSecurityExtensionFactory, + SavedObjectsSpacesExtensionFactory, } from './client_factory'; import type { SavedObjectsType } from './saved_objects_type'; import type { ISavedObjectTypeRegistry } from './type_registry'; import type { ISavedObjectsExporter } from './export'; import type { ISavedObjectsImporter } from './import'; +import type { SavedObjectsExtensions } from './extensions/extensions'; /** * Saved Objects is Kibana's data persistence mechanism allowing plugins to * use Elasticsearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods - * for registering Saved Object types, creating and registering Saved Object client wrappers and factories. + * or registering Saved Object types, and creating and registering Saved Object client factories. * * @remarks * When plugins access the Saved Objects client, a new client is created using - * the factory provided to `setClientFactory` and wrapped by all wrappers - * registered through `addClientWrapper`. + * the factory provided to `setClientFactory`. * * @example * ```ts @@ -67,13 +69,19 @@ export interface SavedObjectsServiceSetup { setClientFactoryProvider: (clientFactoryProvider: SavedObjectsClientFactoryProvider) => void; /** - * Add a {@link SavedObjectsClientWrapperFactory | client wrapper factory} with the given priority. + * Sets the {@link SavedObjectsEncryptionExtensionFactory encryption extension factory}. */ - addClientWrapper: ( - priority: number, - id: string, - factory: SavedObjectsClientWrapperFactory - ) => void; + setEncryptionExtension: (factory: SavedObjectsEncryptionExtensionFactory) => void; + + /** + * Sets the {@link SavedObjectsSecurityExtensionFactory security extension factory}. + */ + setSecurityExtension: (factory: SavedObjectsSecurityExtensionFactory) => void; + + /** + * Sets the {@link SavedObjectsSpacesExtensionFactory spaces extension factory}. + */ + setSpacesExtension: (factory: SavedObjectsSpacesExtensionFactory) => void; /** * Register a {@link SavedObjectsType | savedObjects type} definition. @@ -143,8 +151,7 @@ export interface SavedObjectsServiceStart { /** * Creates a {@link SavedObjectsClientContract | Saved Objects client} that * uses the credentials from the passed in request to authenticate with - * Elasticsearch. If other plugins have registered Saved Objects client - * wrappers, these will be applied to extend the functionality of the client. + * Elasticsearch. * * A client that is already scoped to the incoming request is also exposed * from the route handler context see {@link RequestHandlerContext}. @@ -160,6 +167,7 @@ export interface SavedObjectsServiceStart { * * @param req - The request to create the scoped repository from. * @param includedHiddenTypes - A list of additional hidden types the repository should have access to. + * @param extensions - Extensions that the repository should use (for encryption, security, and spaces). * * @remarks * Prefer using `getScopedClient`. This should only be used when using methods @@ -167,15 +175,20 @@ export interface SavedObjectsServiceStart { */ createScopedRepository: ( req: KibanaRequest, - includedHiddenTypes?: string[] + includedHiddenTypes?: string[], + extensions?: SavedObjectsExtensions ) => ISavedObjectsRepository; /** * Creates a {@link ISavedObjectsRepository | Saved Objects repository} that * uses the internal Kibana user for authenticating with Elasticsearch. * * @param includedHiddenTypes - A list of additional hidden types the repository should have access to. + * @param extensions - Extensions that the repository should use (for encryption, security, and spaces). */ - createInternalRepository: (includedHiddenTypes?: string[]) => ISavedObjectsRepository; + createInternalRepository: ( + includedHiddenTypes?: string[], + extensions?: SavedObjectsExtensions + ) => ISavedObjectsRepository; /** * Creates a {@link ISavedObjectsSerializer | serializer} that is aware of all registered types. */ diff --git a/packages/core/saved-objects/core-saved-objects-server/src/extensions/encryption.ts b/packages/core/saved-objects/core-saved-objects-server/src/extensions/encryption.ts new file mode 100644 index 0000000000000..6dcdac347faca --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-server/src/extensions/encryption.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObject } from '@kbn/core-saved-objects-common'; + +/** + * The EncryptedObjectDescriptor interface contains settings for describing + * an object to be encrypted or decrpyted. + */ +export interface EncryptedObjectDescriptor { + /** The Saved Object type */ + type: string; + /** The Saved Object ID */ + id: string; + /** Namespace for use in index migration... + * If the object is being decrypted during index migration, the object was previously + * encrypted with its namespace in the descriptor portion of the AAD; on the other hand, + * if the object is being decrypted during object migration, the object was never encrypted + * with its namespace in the descriptor portion of the AAD. */ + namespace?: string; +} + +/** + * The ISavedObjectsEncryptionExtension interface defines the functions of a saved objects + * repository encryption extension. It contains functions for determining if a type is + * encryptable, encrypting object attributes, and decrypting or stripping object attributes. + */ +export interface ISavedObjectsEncryptionExtension { + /** + * Returns true if a type has been registered as encryptable. + * @param type - the string name of the object type + * @returns boolean, true if type is encryptable + */ + isEncryptableType: (type: string) => boolean; + + /** + * Given a saved object, will return a decrypted saved object or will strip + * attributes from the returned object if decryption fails. + * @param response - any object R that extends SavedObject with attributes T + * @param originalAttributes - optional, original attributes T from when the object was created (NOT encrypted). + * These are used to avoid decryption execution cost if they are supplied. + * @returns R with decrypted or stripped attributes + */ + decryptOrStripResponseAttributes: >( + response: R, + originalAttributes?: T + ) => Promise; + + /** + * Given a saved object descriptor and some attributes, returns an encrypted version + * of supplied attributes. + * @param descriptor - an object containing a saved object id, type, and optional namespace. + * @param attributes - T, attributes of the specified object, some of which to be encrypted. + * @returns T, encrypted attributes + */ + encryptAttributes: >( + descriptor: EncryptedObjectDescriptor, + attributes: T + ) => Promise; +} diff --git a/packages/core/saved-objects/core-saved-objects-server/src/extensions/extensions.ts b/packages/core/saved-objects/core-saved-objects-server/src/extensions/extensions.ts new file mode 100644 index 0000000000000..f94af85d4ae26 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-server/src/extensions/extensions.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ISavedObjectsEncryptionExtension } from './encryption'; +import type { ISavedObjectsSecurityExtension } from './security'; +import type { ISavedObjectsSpacesExtension } from './spaces'; + +/** + * The SavedObjectsExtensions interface contains the intefaces for three + * extensions to the saved objects repository. These extensions augment + * the funtionality of the saved objects repository to provide encryption, + * security, and spaces features. + */ +export interface SavedObjectsExtensions { + encryptionExtension?: ISavedObjectsEncryptionExtension; + securityExtension?: ISavedObjectsSecurityExtension; + spacesExtension?: ISavedObjectsSpacesExtension; +} + +export const ENCRYPTION_EXTENSION_ID = 'encryptedSavedObjects' as const; +export const SECURITY_EXTENSION_ID = 'security' as const; +export const SPACES_EXTENSION_ID = 'spaces' as const; diff --git a/packages/core/saved-objects/core-saved-objects-server/src/extensions/security.ts b/packages/core/saved-objects/core-saved-objects-server/src/extensions/security.ts new file mode 100644 index 0000000000000..2eadd954e6ad4 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-server/src/extensions/security.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObject } from '@kbn/core-saved-objects-common'; +import type { EcsEventOutcome } from '@kbn/logging'; + +/** + * The CheckAuthorizationParams interface contains settings for checking + * authorization via the ISavedObjectsSecurityExtension. + */ +export interface CheckAuthorizationParams { + /** + * A set of types to check. + */ + types: Set; + /** + * A set of spaces to check. + */ + spaces: Set; + /** + * An set of actions to check. + */ + actions: Set; + /** + * Authorization options - whether or not to allow global resources, false if options are undefined + */ + options?: { + allowGlobalResource: boolean; + }; +} + +/** + * The AuthorizationTypeEntry interface contains space-related details + * for CheckAuthorizationResults. + */ +export interface AuthorizationTypeEntry { + /** + * An array of authorized spaces for the associated type/action + * in the associated record/map. + */ + authorizedSpaces: string[]; + /** + * Is the associated type/action globally authorized? + */ + isGloballyAuthorized?: boolean; +} + +/** + * The AuthorizationTypeMap type is a map of saved object type + * to a record of action/AuthorizationTypeEntry, + */ +export type AuthorizationTypeMap = Map>; + +/** + * The CheckAuthorizationResult interface contains the overall status of an + * authorization check and the specific authorized privileges as an + * AuthorizationTypeMap. + */ +export interface CheckAuthorizationResult { + /** + * The overall status of the authorization check as a string: + * 'fully_authorized' | 'partially_authorized' | 'unauthorized' + */ + status: 'fully_authorized' | 'partially_authorized' | 'unauthorized'; + /** + * The specific authorized privileges: a map of type to record + * of action/AuthorizationTypeEntry (spaces/globallyAuthz'd) + */ + typeMap: AuthorizationTypeMap; +} + +/** + * The EnforceAuthorizationParams interface contains settings for + * enforcing a single action via the ISavedObjectsSecurityExtension. + */ +export interface EnforceAuthorizationParams { + /** + * A map of types to spaces that will be affected by the action + */ + typesAndSpaces: Map>; + /** + * The relevant action (create, update, etc.) + */ + action: A; + /** + * The authorization map from CheckAuthorizationResult: a + * map of type to record of action/AuthorizationTypeEntry + * (spaces/globallyAuthz'd) + */ + typeMap: AuthorizationTypeMap; + /** + * A callback intended to handle adding audit events in + * both error (unauthorized), or success (authorized) + * cases + */ + auditCallback?: (error?: Error) => void; +} + +/** + * The AuditAction enumeration contains values for all + * valid audit actions for use in AddAuditEventParams. + */ +export enum AuditAction { + CREATE = 'saved_object_create', + GET = 'saved_object_get', + RESOLVE = 'saved_object_resolve', + UPDATE = 'saved_object_update', + DELETE = 'saved_object_delete', + FIND = 'saved_object_find', + REMOVE_REFERENCES = 'saved_object_remove_references', + OPEN_POINT_IN_TIME = 'saved_object_open_point_in_time', + CLOSE_POINT_IN_TIME = 'saved_object_close_point_in_time', + COLLECT_MULTINAMESPACE_REFERENCES = 'saved_object_collect_multinamespace_references', // this is separate from 'saved_object_get' because the user is only accessing an object's metadata + UPDATE_OBJECTS_SPACES = 'saved_object_update_objects_spaces', // this is separate from 'saved_object_update' because the user is only updating an object's metadata +} + +/** + * The AddAuditEventParams interface contains settings for adding + * audit events via the ISavedObjectsSecurityExtension. + */ +export interface AddAuditEventParams { + /** + * The relevant action + */ + action: AuditAction; + /** + * The outcome of the operation + * 'failure' | 'success' | 'unknown' + */ + outcome?: EcsEventOutcome; + /** + * relevant saved object information + * object containing type & id strings + */ + savedObject?: { type: string; id: string }; + /** + * Array of spaces being added. For + * UPDATE_OBJECTS_SPACES action only + */ + addToSpaces?: readonly string[]; + /** + * Array of spaces being removed. For + * UPDATE_OBJECTS_SPACES action only + */ + deleteFromSpaces?: readonly string[]; + /** + * relevant error information to add to + * the audit event + */ + error?: Error; +} + +/** + * The RedactNamespacesParams interface contains settings for filtering + * namespace access via the ISavedObjectsSecurityExtension. + */ +export interface RedactNamespacesParams { + /** + * relevant saved object + */ + savedObject: SavedObject; + /** + * The authorization map from CheckAuthorizationResult: a map of + * type to record of action/AuthorizationTypeEntry + * (spaces/globallyAuthz'd) + */ + typeMap: AuthorizationTypeMap; +} + +/** + * The ISavedObjectsSecurityExtension interface defines the functions of a saved objects repository security extension. + * It contains functions for checking & enforcing authorization, adding audit events, and redacting namespaces. + */ +export interface ISavedObjectsSecurityExtension { + /** + * Checks authorization of actions on specified types in specified spaces. + * @param params - types, spaces, and actions to check + * @returns CheckAuthorizationResult - the resulting authorization level and authorization map + */ + checkAuthorization: ( + params: CheckAuthorizationParams + ) => Promise>; + + /** + * Enforces authorization of a single action on specified types in specified spaces. + * Throws error if authorization map does not cover specified parameters. + * @param params - map of types/spaces, action to check, and authz map (from CheckAuthorizationResult) + */ + enforceAuthorization: (params: EnforceAuthorizationParams) => void; + + /** + * Adds an audit event for the specified action with relevant information + * @param params - the action, outcome, error, and relevant object/space information + */ + addAuditEvent: (params: AddAuditEventParams) => void; + + /** + * Filters a saved object's spaces based on an authorization map (from CheckAuthorizationResult) + * @param params - the saved object and an authorization map + * @returns SavedObject - saved object with filtered spaces + */ + redactNamespaces: (params: RedactNamespacesParams) => SavedObject; +} diff --git a/packages/core/saved-objects/core-saved-objects-server/src/extensions/spaces.ts b/packages/core/saved-objects/core-saved-objects-server/src/extensions/spaces.ts new file mode 100644 index 0000000000000..dd5ab0e3f56d2 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-server/src/extensions/spaces.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * The ISavedObjectsSpacesExtension interface defines the functions of a saved objects repository spaces extension. + * It contains functions for getting the current namespace & getting and array of searchable spaces. + */ +export interface ISavedObjectsSpacesExtension { + /** + * Retrieves the active namespace ID. This is *not* the same as a namespace string. See also: `namespaceIdToString` and + * `namespaceStringToId`. + * + * This takes the saved objects repository's namespace option as a parameter, and doubles as a validation function; if the namespace + * option has already been set some other way, this will throw an error. + */ + getCurrentNamespace: (namespace: string | undefined) => string | undefined; + /** + * Given a list of namespace strings, returns a subset that the user is authorized to search in. + * If a wildcard '*' is used, it is expanded to an explicit list of namespace strings. + */ + getSearchableNamespaces: (namespaces: string[] | undefined) => Promise; +} diff --git a/packages/core/saved-objects/core-saved-objects-utils-server/src/merge_migration_maps.ts b/packages/core/saved-objects/core-saved-objects-utils-server/src/merge_migration_maps.ts index e5f2f1d74a7cf..6d7c1c90d25df 100644 --- a/packages/core/saved-objects/core-saved-objects-utils-server/src/merge_migration_maps.ts +++ b/packages/core/saved-objects/core-saved-objects-utils-server/src/merge_migration_maps.ts @@ -27,6 +27,10 @@ import type { * mergeSavedObjectMigrationMaps({ '1.2.3': f }, { '1.2.3': g }) -> { '1.2.3': (doc, context) => f(g(doc, context), context) } * * @public + * + * @param map1 - The first map to merge + * @param map2 - The second map to merge + * @returns The merged map {@link SavedObjectMigrationMap} */ export const mergeSavedObjectMigrationMaps = ( map1: SavedObjectMigrationMap, diff --git a/packages/core/saved-objects/core-saved-objects-utils-server/src/saved_objects_utils.ts b/packages/core/saved-objects/core-saved-objects-utils-server/src/saved_objects_utils.ts index 46f562d17aec3..240abb53db289 100644 --- a/packages/core/saved-objects/core-saved-objects-utils-server/src/saved_objects_utils.ts +++ b/packages/core/saved-objects/core-saved-objects-utils-server/src/saved_objects_utils.ts @@ -53,7 +53,7 @@ export class SavedObjectsUtils { }; /** - * Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. + * Creates an empty response for a find operation. */ public static createEmptyFindResponse = ({ page = FIND_DEFAULT_PAGE, diff --git a/src/core/server/index.ts b/src/core/server/index.ts index e8df881dcaac0..c05faf0cc6a89 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -245,6 +245,7 @@ export type { export type { PluginName, DiscoveredPlugin } from '@kbn/core-base-common'; +export type { SavedObjectsStart } from '@kbn/core-saved-objects-browser'; export type { SavedObject, SavedObjectAttribute, @@ -265,6 +266,7 @@ export type { SavedObjectsImportSimpleWarning, SavedObjectsImportActionRequiredWarning, SavedObjectsImportWarning, + SavedObjectTypeIdTuple, } from '@kbn/core-saved-objects-common'; export type { SavedObjectsBulkCreateObject, @@ -314,15 +316,19 @@ export type { SavedObjectsBulkDeleteObject, SavedObjectsBulkDeleteOptions, SavedObjectsBulkDeleteResponse, + SavedObjectsPointInTimeFinderClient, + SavedObjectsBulkDeleteStatus, } from '@kbn/core-saved-objects-api-server'; export type { SavedObjectsServiceSetup, SavedObjectsServiceStart, SavedObjectsClientProviderOptions, - SavedObjectsClientWrapperFactory, - SavedObjectsClientWrapperOptions, SavedObjectsClientFactory, SavedObjectsClientFactoryProvider, + SavedObjectsEncryptionExtensionFactory, + SavedObjectsSecurityExtensionFactory, + SavedObjectsSpacesExtensionFactory, + SavedObjectsExtensionFactory, SavedObjectTypeExcludeFromUpgradeFilterHook, SavedObjectsExportResultDetails, SavedObjectsExportExcludedObject, @@ -358,6 +364,23 @@ export type { SavedObjectsValidationSpec, ISavedObjectsSerializer, SavedObjectsRequestHandlerContext, + EncryptedObjectDescriptor, + ISavedObjectsEncryptionExtension, + CheckAuthorizationParams, + AuthorizationTypeEntry, + AuthorizationTypeMap, + CheckAuthorizationResult, + EnforceAuthorizationParams, + AddAuditEventParams, + RedactNamespacesParams, + ISavedObjectsSecurityExtension, + ISavedObjectsSpacesExtension, + SavedObjectsExtensions, +} from '@kbn/core-saved-objects-server'; +export { + ENCRYPTION_EXTENSION_ID, + SECURITY_EXTENSION_ID, + SPACES_EXTENSION_ID, } from '@kbn/core-saved-objects-server'; export { SavedObjectsErrorHelpers, diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts index deea86bbdd18c..3550d93b1edf9 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -84,12 +84,14 @@ describe('telemetry_application_usage', () => { expect(savedObjectClient.find).toHaveBeenCalledWith( expect.objectContaining({ type: SAVED_OBJECTS_TOTAL_TYPE, - }) + }), + undefined // internalOptions ); expect(savedObjectClient.find).toHaveBeenCalledWith( expect.objectContaining({ type: SAVED_OBJECTS_DAILY_TYPE, - }) + }), + undefined // internalOptions ); }); diff --git a/src/plugins/saved_objects_management/server/lib/find_all.test.ts b/src/plugins/saved_objects_management/server/lib/find_all.test.ts index 59c2efd36a8f4..13135ce41b06e 100644 --- a/src/plugins/saved_objects_management/server/lib/find_all.test.ts +++ b/src/plugins/saved_objects_management/server/lib/find_all.test.ts @@ -62,7 +62,8 @@ describe('findAll', () => { expect(savedObjectsClient.find).toHaveBeenCalledWith( expect.objectContaining({ ...query, - }) + }), + undefined // internalOptions ); expect(results).toEqual([createObj(1), createObj(2)]); diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index fb573f0973a58..178ceb8f2b95e 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -19,7 +19,7 @@ import { SavedObjectsClientContract, SavedObjectsBulkGetObject, } from '@kbn/core/server'; - +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import { EncryptedSavedObjectsClient, EncryptedSavedObjectsPluginSetup, @@ -570,7 +570,7 @@ export class ActionsPlugin implements Plugin savedObjects.getScopedClient(request, { - excludedWrappers: ['security'], + excludedExtensions: [SECURITY_EXTENSION_ID], includedHiddenTypes, }); @@ -633,7 +633,7 @@ export class ActionsPlugin implements Plugin { factory.create(request, savedObjectsService); expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { - excludedWrappers: ['security'], + excludedExtensions: [SECURITY_EXTENSION_ID], includedHiddenTypes: ['alert', 'api_key_pending_invalidation'], }); diff --git a/x-pack/plugins/alerting/server/rules_client_factory.ts b/x-pack/plugins/alerting/server/rules_client_factory.ts index d7dc9612b603a..e6d77b618f0b0 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.ts @@ -16,6 +16,7 @@ import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/s import { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import { IEventLogClientService, IEventLogger } from '@kbn/event-log-plugin/server'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import { RuleTypeRegistry, SpaceIdToNamespaceFunction } from './types'; import { RulesClient } from './rules_client'; import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; @@ -91,7 +92,7 @@ export class RulesClientFactory { ruleTypeRegistry: this.ruleTypeRegistry, minimumScheduleInterval: this.minimumScheduleInterval, unsecuredSavedObjectsClient: savedObjects.getScopedClient(request, { - excludedWrappers: ['security'], + excludedExtensions: [SECURITY_EXTENSION_ID], includedHiddenTypes: ['alert', 'api_key_pending_invalidation'], }), authorization: this.authorization.create(request), diff --git a/x-pack/plugins/cases/server/client/factory.ts b/x-pack/plugins/cases/server/client/factory.ts index 245a34c81b8fd..b054903c8a2d0 100644 --- a/x-pack/plugins/cases/server/client/factory.ts +++ b/x-pack/plugins/cases/server/client/factory.ts @@ -13,6 +13,7 @@ import type { SavedObjectsClientContract, IBasePath, } from '@kbn/core/server'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; import type { PluginStartContract as FeaturesPluginStart } from '@kbn/features-plugin/server'; import type { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; @@ -111,7 +112,7 @@ export class CasesClientFactory { includedHiddenTypes: SAVED_OBJECT_TYPES, // this tells the security plugin to not perform SO authorization and audit logging since we are handling // that manually using our Authorization class and audit logger. - excludedWrappers: ['security'], + excludedExtensions: [SECURITY_EXTENSION_ID], }); const services = this.createServices({ diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.test.ts index a1c598bef2413..acd5c20fd1efd 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ENCRYPTION_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import type { SavedObject, SavedObjectsClientContract, @@ -70,7 +71,7 @@ beforeEach(() => { mockRetrieveClient.find.mockResolvedValue({ total: 0, saved_objects: [], per_page: 0, page: 0 }); mockUpdateClient = savedObjectsClientMock.create(); mockSavedObjects.getScopedClient.mockImplementation((request, params) => - params?.excludedWrappers?.[0] === 'encryptedSavedObjects' + params?.excludedExtensions?.[0] === ENCRYPTION_EXTENSION_ID ? mockRetrieveClient : mockUpdateClient ); @@ -89,7 +90,7 @@ it('correctly setups Saved Objects clients', async () => { expect(mockSavedObjects.getScopedClient).toHaveBeenCalledTimes(2); expect(mockSavedObjects.getScopedClient).toHaveBeenCalledWith(mockRequest, { includedHiddenTypes: ['type-id-2', 'type-id-4'], - excludedWrappers: ['encryptedSavedObjects'], + excludedExtensions: [ENCRYPTION_EXTENSION_ID], }); expect(mockSavedObjects.getScopedClient).toHaveBeenCalledWith(mockRequest, { includedHiddenTypes: ['type-id-2', 'type-id-4'], diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts index 6caf76e63508a..9de649a8cf5e7 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ENCRYPTION_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import type { ISavedObjectTypeRegistry, KibanaRequest, @@ -107,7 +108,7 @@ export class EncryptionKeyRotationService { const user = this.options.security?.authc.getCurrentUser(request) ?? undefined; const retrieveClient = savedObjects.getScopedClient(request, { includedHiddenTypes: registeredHiddenSavedObjectTypes, - excludedWrappers: ['encryptedSavedObjects'], + excludedExtensions: [ENCRYPTION_EXTENSION_ID], }); const updateClient = savedObjects.getScopedClient(request, { includedHiddenTypes: registeredHiddenSavedObjectTypes, diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts deleted file mode 100644 index ab8b03840c819..0000000000000 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ /dev/null @@ -1,2200 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { SavedObjectsBulkResolveResponse, SavedObjectsClientContract } from '@kbn/core/server'; -import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from '@kbn/core/server/mocks'; -import { mockAuthenticatedUser } from '@kbn/security-plugin/common/model/authenticated_user.mock'; - -import type { EncryptedSavedObjectsService } from '../crypto'; -import { EncryptionError } from '../crypto'; -import { EncryptionErrorOperation } from '../crypto/encryption_error'; -import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock'; -import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; - -jest.mock('@kbn/core-saved-objects-utils-server', () => { - const { SavedObjectsUtils, ...actual } = jest.requireActual( - '@kbn/core-saved-objects-utils-server' - ); - return { - ...actual, - SavedObjectsUtils: { - namespaceStringToId: SavedObjectsUtils.namespaceStringToId, - isRandomId: SavedObjectsUtils.isRandomId, - generateId: () => 'mock-saved-object-id', - }, - }; -}); - -let wrapper: EncryptedSavedObjectsClientWrapper; -let mockBaseClient: jest.Mocked; -let mockBaseTypeRegistry: ReturnType; -let encryptedSavedObjectsServiceMockInstance: jest.Mocked; -beforeEach(() => { - mockBaseClient = savedObjectsClientMock.create(); - mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); - encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.createWithTypes([ - { - type: 'known-type', - attributesToEncrypt: new Set([ - 'attrSecret', - { key: 'attrNotSoSecret', dangerouslyExposeValue: true }, - ]), - }, - ]); - - wrapper = new EncryptedSavedObjectsClientWrapper({ - service: encryptedSavedObjectsServiceMockInstance, - baseClient: mockBaseClient, - baseTypeRegistry: mockBaseTypeRegistry, - getCurrentUser: () => mockAuthenticatedUser(), - } as any); -}); - -afterEach(() => jest.clearAllMocks()); - -describe('#checkConflicts', () => { - it('redirects request to underlying base client', async () => { - const objects = [{ type: 'foo', id: 'bar' }]; - const options = { namespace: 'some-namespace' }; - const mockedResponse = { errors: [] }; - mockBaseClient.checkConflicts.mockResolvedValue(mockedResponse); - - await expect(wrapper.checkConflicts(objects, options)).resolves.toEqual(mockedResponse); - expect(mockBaseClient.checkConflicts).toHaveBeenCalledTimes(1); - expect(mockBaseClient.checkConflicts).toHaveBeenCalledWith(objects, options); - }); -}); - -describe('#create', () => { - it('redirects request to underlying base client if type is not registered', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { id: 'some-non-uuid-v4-id' }; - const mockedResponse = { id: options.id, type: 'unknown-type', attributes, references: [] }; - - mockBaseClient.create.mockResolvedValue(mockedResponse); - - await expect(wrapper.create('unknown-type', attributes, options)).resolves.toEqual({ - ...mockedResponse, - id: options.id, - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - }); - expect(mockBaseClient.create).toHaveBeenCalledTimes(1); - expect(mockBaseClient.create).toHaveBeenCalledWith('unknown-type', attributes, options); - }); - - it('fails if type is registered and non-UUID ID is specified', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - - await expect(wrapper.create('known-type', attributes, { id: 'some-id' })).rejects.toThrowError( - 'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.' - ); - - expect(mockBaseClient.create).not.toHaveBeenCalled(); - }); - - it('allows a specified ID when overwriting an existing object', async () => { - const attributes = { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }; - const options = { id: 'predefined-uuid', overwrite: true, version: 'some-version' }; - const mockedResponse = { - id: 'predefined-uuid', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - references: [], - }; - - mockBaseClient.create.mockResolvedValue(mockedResponse); - - expect(await wrapper.create('known-type', attributes, options)).toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'predefined-uuid' }, - { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - { user: mockAuthenticatedUser() } - ); - - expect(mockBaseClient.create).toHaveBeenCalledTimes(1); - expect(mockBaseClient.create).toHaveBeenCalledWith( - 'known-type', - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - { id: 'predefined-uuid', overwrite: true, version: 'some-version' } - ); - }); - - it('generates ID, encrypts attributes and strips them from response except for ones with `dangerouslyExposeValue` set to `true`', async () => { - const attributes = { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }; - const options = { overwrite: true }; - const mockedResponse = { - id: 'mock-saved-object-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - references: [], - }; - - mockBaseClient.create.mockResolvedValue(mockedResponse); - - expect(await wrapper.create('known-type', attributes, options)).toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'mock-saved-object-id' }, - { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - { user: mockAuthenticatedUser() } - ); - - expect(mockBaseClient.create).toHaveBeenCalledTimes(1); - expect(mockBaseClient.create).toHaveBeenCalledWith( - 'known-type', - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - { id: 'mock-saved-object-id', overwrite: true } - ); - }); - - describe('namespace', () => { - const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { overwrite: true, namespace }; - const mockedResponse = { - id: 'mock-saved-object-id', - type: 'known-type', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - references: [], - }; - - mockBaseClient.create.mockResolvedValue(mockedResponse); - - expect(await wrapper.create('known-type', attributes, options)).toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { - type: 'known-type', - id: 'mock-saved-object-id', - namespace: expectNamespaceInDescriptor ? namespace : undefined, - }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - { user: mockAuthenticatedUser() } - ); - - expect(mockBaseClient.create).toHaveBeenCalledTimes(1); - expect(mockBaseClient.create).toHaveBeenCalledWith( - 'known-type', - { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - { id: 'mock-saved-object-id', overwrite: true, namespace } - ); - }; - - it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => { - await doTest('some-namespace', true); - }); - - it('does not use `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { - mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); - await doTest('some-namespace', false); - }); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.create.mockRejectedValue(failureReason); - - await expect( - wrapper.create('known-type', { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }) - ).rejects.toThrowError(failureReason); - - expect(mockBaseClient.create).toHaveBeenCalledTimes(1); - expect(mockBaseClient.create).toHaveBeenCalledWith( - 'known-type', - { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - { id: 'mock-saved-object-id' } - ); - }); -}); - -describe('#bulkCreate', () => { - it('does not fail if ID is specified for not registered type', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { namespace: 'some-namespace' }; - const mockedResponse = { - saved_objects: [ - { - id: 'mock-saved-object-id', - type: 'known-type', - attributes, - references: [], - }, - { - id: 'some-id', - type: 'unknown-type', - attributes, - references: [], - }, - ], - }; - - mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); - - const bulkCreateParams = [ - { type: 'known-type', attributes }, - { id: 'some-id', type: 'unknown-type', attributes }, - ]; - - await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({ - saved_objects: [ - { ...mockedResponse.saved_objects[0], attributes: { attrOne: 'one', attrThree: 'three' } }, - mockedResponse.saved_objects[1], - ], - }); - - expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith( - [ - { - ...bulkCreateParams[0], - id: 'mock-saved-object-id', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - }, - bulkCreateParams[1], - ], - options - ); - }); - - it('fails if non-UUID ID is specified for registered type', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - - const bulkCreateParams = [ - { id: 'some-id', type: 'known-type', attributes }, - { type: 'unknown-type', attributes }, - ]; - - await expect(wrapper.bulkCreate(bulkCreateParams)).rejects.toThrowError( - 'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.' - ); - - expect(mockBaseClient.bulkCreate).not.toHaveBeenCalled(); - }); - - it('allows a specified ID when overwriting an existing object', async () => { - const attributes = { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }; - const mockedResponse = { - saved_objects: [ - { - id: 'predefined-uuid', - type: 'known-type', - attributes: { ...attributes, attrSecret: '*secret*', attrNotSoSecret: '*not-so-secret*' }, - references: [], - }, - { - id: 'some-id', - type: 'unknown-type', - attributes, - references: [], - }, - ], - }; - - mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); - - const bulkCreateParams = [ - { id: 'predefined-uuid', type: 'known-type', attributes, version: 'some-version' }, - { type: 'unknown-type', attributes }, - ]; - - await expect(wrapper.bulkCreate(bulkCreateParams, { overwrite: true })).resolves.toEqual({ - saved_objects: [ - { - ...mockedResponse.saved_objects[0], - attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, - }, - mockedResponse.saved_objects[1], - ], - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'predefined-uuid' }, - { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - { user: mockAuthenticatedUser() } - ); - - expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith( - [ - { - ...bulkCreateParams[0], - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - }, - bulkCreateParams[1], - ], - { overwrite: true } - ); - }); - - it('generates ID, encrypts attributes and strips them from response except for ones with `dangerouslyExposeValue` set to `true`', async () => { - const attributes = { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }; - const mockedResponse = { - saved_objects: [ - { - id: 'mock-saved-object-id', - type: 'known-type', - attributes: { ...attributes, attrSecret: '*secret*', attrNotSoSecret: '*not-so-secret*' }, - references: [], - }, - { - id: 'some-id', - type: 'unknown-type', - attributes, - references: [], - }, - ], - }; - - mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); - - const bulkCreateParams = [ - { type: 'known-type', attributes }, - { type: 'unknown-type', attributes }, - ]; - - await expect(wrapper.bulkCreate(bulkCreateParams)).resolves.toEqual({ - saved_objects: [ - { - ...mockedResponse.saved_objects[0], - attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, - }, - mockedResponse.saved_objects[1], - ], - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'mock-saved-object-id' }, - { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - { user: mockAuthenticatedUser() } - ); - - expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith( - [ - { - ...bulkCreateParams[0], - id: 'mock-saved-object-id', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - }, - bulkCreateParams[1], - ], - undefined - ); - }); - - describe('namespace', () => { - const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { namespace }; - const mockedResponse = { - saved_objects: [ - { id: 'mock-saved-object-id', type: 'known-type', attributes, references: [] }, - ], - }; - - mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); - - const bulkCreateParams = [{ type: 'known-type', attributes }]; - await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({ - saved_objects: [ - { - ...mockedResponse.saved_objects[0], - attributes: { attrOne: 'one', attrThree: 'three' }, - }, - ], - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { - type: 'known-type', - id: 'mock-saved-object-id', - namespace: expectNamespaceInDescriptor ? namespace : undefined, - }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - { user: mockAuthenticatedUser() } - ); - - expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith( - [ - { - ...bulkCreateParams[0], - id: 'mock-saved-object-id', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - }, - ], - options - ); - }; - - it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => { - await doTest('some-namespace', true); - }); - - it('does not use `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { - mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); - await doTest('some-namespace', false); - }); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.bulkCreate.mockRejectedValue(failureReason); - - await expect( - wrapper.bulkCreate([ - { - type: 'known-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - }, - ]) - ).rejects.toThrowError(failureReason); - - expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith( - [ - { - type: 'known-type', - id: 'mock-saved-object-id', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - }, - ], - undefined - ); - }); -}); - -describe('#bulkUpdate', () => { - it('redirects request to underlying base client if type is not registered', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const mockedResponse = { - saved_objects: [{ id: 'some-id', type: 'unknown-type', attributes, references: [] }], - }; - - mockBaseClient.bulkUpdate.mockResolvedValue(mockedResponse); - - await expect( - wrapper.bulkUpdate( - [{ type: 'unknown-type', id: 'some-id', attributes, version: 'some-version' }], - {} - ) - ).resolves.toEqual(mockedResponse); - - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith( - [{ type: 'unknown-type', id: 'some-id', attributes, version: 'some-version' }], - {} - ); - }); - - it('encrypts attributes and strips them from response except for ones with `dangerouslyExposeValue` set to `true`', async () => { - const docs = [ - { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - }, - { - id: 'some-id-2', - type: 'known-type', - attributes: { - attrOne: 'one 2', - attrSecret: 'secret 2', - attrNotSoSecret: 'not-so-secret 2', - attrThree: 'three 2', - }, - }, - ]; - - const mockedResponse = { - saved_objects: docs.map((doc) => ({ - ...doc, - attributes: { - ...doc.attributes, - attrSecret: `*${doc.attributes.attrSecret}*`, - attrNotSoSecret: `*${doc.attributes.attrNotSoSecret}*`, - }, - references: undefined, - })), - }; - - mockBaseClient.bulkUpdate.mockResolvedValue(mockedResponse); - - await expect( - wrapper.bulkUpdate( - docs.map((doc) => ({ ...doc })), - {} - ) - ).resolves.toEqual({ - saved_objects: [ - { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - }, - { - id: 'some-id-2', - type: 'known-type', - attributes: { - attrOne: 'one 2', - attrNotSoSecret: 'not-so-secret 2', - attrThree: 'three 2', - }, - }, - ], - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(2); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id' }, - { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - { user: mockAuthenticatedUser() } - ); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id-2' }, - { - attrOne: 'one 2', - attrSecret: 'secret 2', - attrNotSoSecret: 'not-so-secret 2', - attrThree: 'three 2', - }, - { user: mockAuthenticatedUser() } - ); - - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith( - [ - { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - }, - { - id: 'some-id-2', - type: 'known-type', - attributes: { - attrOne: 'one 2', - attrSecret: '*secret 2*', - attrNotSoSecret: '*not-so-secret 2*', - attrThree: 'three 2', - }, - }, - ], - {} - ); - }); - - describe('namespace', () => { - interface TestParams { - optionsNamespace: string | undefined; - objectNamespace: string | undefined; - expectOptionsNamespaceInDescriptor: boolean; - expectObjectNamespaceInDescriptor: boolean; - } - - const doTest = async ({ - optionsNamespace, - objectNamespace, - expectOptionsNamespaceInDescriptor, - expectObjectNamespaceInDescriptor, - }: TestParams) => { - const docs = [ - { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrThree: 'three', - }, - version: 'some-version', - namespace: objectNamespace, - }, - ]; - const options = { namespace: optionsNamespace }; - - mockBaseClient.bulkUpdate.mockResolvedValue({ - saved_objects: docs.map(({ namespace, ...doc }) => ({ ...doc, references: undefined })), - }); - - await expect(wrapper.bulkUpdate(docs, options)).resolves.toEqual({ - saved_objects: [ - { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrThree: 'three', - }, - version: 'some-version', - references: undefined, - }, - ], - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { - type: 'known-type', - id: 'some-id', - namespace: expectObjectNamespaceInDescriptor - ? objectNamespace - : expectOptionsNamespaceInDescriptor - ? optionsNamespace - : undefined, - }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - { user: mockAuthenticatedUser() } - ); - - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith( - [ - { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrThree: 'three', - }, - version: 'some-version', - namespace: objectNamespace, - references: undefined, - }, - ], - options - ); - }; - - it('does not use options `namespace` or object `namespace` to encrypt attributes if neither are specified', async () => { - await doTest({ - optionsNamespace: undefined, - objectNamespace: undefined, - expectOptionsNamespaceInDescriptor: false, - expectObjectNamespaceInDescriptor: false, - }); - }); - - describe('with a single-namespace type', () => { - it('uses options `namespace` to encrypt attributes if it is specified and object `namespace` is not', async () => { - await doTest({ - optionsNamespace: 'some-namespace', - objectNamespace: undefined, - expectOptionsNamespaceInDescriptor: true, - expectObjectNamespaceInDescriptor: false, - }); - }); - - it('uses object `namespace` to encrypt attributes if it is specified', async () => { - // object namespace supersedes options namespace - await doTest({ - optionsNamespace: 'some-namespace', - objectNamespace: 'another-namespace', - expectOptionsNamespaceInDescriptor: false, - expectObjectNamespaceInDescriptor: true, - }); - }); - }); - - describe('with a non-single-namespace type', () => { - it('does not use object `namespace` or options `namespace` to encrypt attributes if it is specified', async () => { - mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); - await doTest({ - optionsNamespace: 'some-namespace', - objectNamespace: 'another-namespace', - expectOptionsNamespaceInDescriptor: false, - expectObjectNamespaceInDescriptor: false, - }); - }); - }); - }); - - it('fails if base client fails', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - - const failureReason = new Error('Something bad happened...'); - mockBaseClient.bulkUpdate.mockRejectedValue(failureReason); - - await expect( - wrapper.bulkUpdate( - [{ type: 'unknown-type', id: 'some-id', attributes, version: 'some-version' }], - {} - ) - ).rejects.toThrowError(failureReason); - - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith( - [{ type: 'unknown-type', id: 'some-id', attributes, version: 'some-version' }], - {} - ); - }); -}); - -describe('#delete', () => { - it('redirects request to underlying base client if type is not registered', async () => { - const options = { namespace: 'some-ns' }; - - await wrapper.delete('unknown-type', 'some-id', options); - - expect(mockBaseClient.delete).toHaveBeenCalledTimes(1); - expect(mockBaseClient.delete).toHaveBeenCalledWith('unknown-type', 'some-id', options); - }); - - it('redirects request to underlying base client if type is registered', async () => { - const options = { namespace: 'some-ns' }; - - await wrapper.delete('known-type', 'some-id', options); - - expect(mockBaseClient.delete).toHaveBeenCalledTimes(1); - expect(mockBaseClient.delete).toHaveBeenCalledWith('known-type', 'some-id', options); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.delete.mockRejectedValue(failureReason); - - await expect(wrapper.delete('known-type', 'some-id')).rejects.toThrowError(failureReason); - - expect(mockBaseClient.delete).toHaveBeenCalledTimes(1); - expect(mockBaseClient.delete).toHaveBeenCalledWith('known-type', 'some-id', undefined); - }); -}); - -describe('#bulkDelete', () => { - const obj1 = Object.freeze({ type: 'unknown-type', id: 'unknown-type-id-1' }); - const obj2 = Object.freeze({ type: 'unknown-type', id: 'unknown-type-id-2' }); - const namespace = 'some-ns'; - - it('redirects request to underlying base client if type is not registered', async () => { - await wrapper.bulkDelete([obj1, obj2], { namespace }); - expect(mockBaseClient.bulkDelete).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkDelete).toHaveBeenCalledWith([obj1, obj2], { namespace }); - }); - - it('redirects request to underlying base client if type is registered', async () => { - const knownObj1 = Object.freeze({ type: 'known-type', id: 'known-type-id-1' }); - const knownObj2 = Object.freeze({ type: 'known-type', id: 'known-type-id-2' }); - const options = { namespace: 'some-ns' }; - - await wrapper.bulkDelete([knownObj1, knownObj2], options); - - expect(mockBaseClient.bulkDelete).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkDelete).toHaveBeenCalledWith([knownObj1, knownObj2], { namespace }); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.bulkDelete.mockRejectedValue(failureReason); - - await expect(wrapper.bulkDelete([{ type: 'known-type', id: 'some-id' }])).rejects.toThrowError( - failureReason - ); - - expect(mockBaseClient.bulkDelete).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkDelete).toHaveBeenCalledWith( - [{ type: 'known-type', id: 'some-id' }], - undefined - ); - }); -}); - -describe('#find', () => { - it('redirects request to underlying base client and does not alter response if type is not registered', async () => { - const mockedResponse = { - saved_objects: [ - { - id: 'some-id', - type: 'unknown-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - score: 1, - references: [], - }, - { - id: 'some-id-2', - type: 'unknown-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - score: 1, - references: [], - }, - ], - total: 2, - per_page: 2, - page: 1, - }; - - mockBaseClient.find.mockResolvedValue(mockedResponse); - - const options = { type: 'unknown-type', search: 'query' }; - await expect(wrapper.find(options)).resolves.toEqual({ - ...mockedResponse, - saved_objects: [ - { - ...mockedResponse.saved_objects[0], - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - }, - { - ...mockedResponse.saved_objects[1], - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - }, - ], - }); - expect(mockBaseClient.find).toHaveBeenCalledTimes(1); - expect(mockBaseClient.find).toHaveBeenCalledWith(options); - }); - - it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { - const mockedResponse = { - saved_objects: [ - { - id: 'some-id', - type: 'unknown-type', - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - score: 1, - references: [], - }, - { - id: 'some-id-2', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - score: 1, - references: [], - }, - ], - total: 2, - per_page: 2, - page: 1, - }; - - mockBaseClient.find.mockResolvedValue(mockedResponse); - - const options = { type: ['unknown-type', 'known-type'], search: 'query' }; - await expect(wrapper.find(options)).resolves.toEqual({ - ...mockedResponse, - saved_objects: [ - { - ...mockedResponse.saved_objects[0], - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - }, - { - ...mockedResponse.saved_objects[1], - attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, - }, - ], - }); - expect(mockBaseClient.find).toHaveBeenCalledTimes(1); - expect(mockBaseClient.find).toHaveBeenCalledWith(options); - - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( - 1 - ); - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id-2' }, - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - undefined, - { user: mockAuthenticatedUser() } - ); - }); - - it('includes both attributes and error if decryption fails.', async () => { - const mockedResponse = { - saved_objects: [ - { - id: 'some-id', - type: 'unknown-type', - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - score: 1, - references: [], - }, - { - id: 'some-id-2', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - score: 1, - references: [], - }, - ], - total: 2, - per_page: 2, - page: 1, - }; - - mockBaseClient.find.mockResolvedValue(mockedResponse); - - const decryptionError = new EncryptionError( - 'something failed', - 'attrNotSoSecret', - EncryptionErrorOperation.Decryption - ); - encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ - attributes: { attrOne: 'one', attrThree: 'three' }, - error: decryptionError, - }); - - const options = { type: ['unknown-type', 'known-type'], search: 'query' }; - await expect(wrapper.find(options)).resolves.toEqual({ - ...mockedResponse, - saved_objects: [ - { - ...mockedResponse.saved_objects[0], - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - }, - { - ...mockedResponse.saved_objects[1], - attributes: { attrOne: 'one', attrThree: 'three' }, - error: decryptionError, - }, - ], - }); - expect(mockBaseClient.find).toHaveBeenCalledTimes(1); - expect(mockBaseClient.find).toHaveBeenCalledWith(options); - - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( - 1 - ); - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id-2' }, - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - undefined, - { user: mockAuthenticatedUser() } - ); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.find.mockRejectedValue(failureReason); - - await expect(wrapper.find({ type: 'known-type' })).rejects.toThrowError(failureReason); - - expect(mockBaseClient.find).toHaveBeenCalledTimes(1); - expect(mockBaseClient.find).toHaveBeenCalledWith({ type: 'known-type' }); - }); -}); - -describe('#bulkGet', () => { - it('redirects request to underlying base client and does not alter response if type is not registered', async () => { - const mockedResponse = { - saved_objects: [ - { - id: 'some-id', - type: 'unknown-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - references: [], - }, - { - id: 'some-id-2', - type: 'unknown-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - references: [], - }, - ], - total: 2, - per_page: 2, - page: 1, - }; - - mockBaseClient.bulkGet.mockResolvedValue(mockedResponse); - - const bulkGetParams = [ - { type: 'unknown-type', id: 'some-id' }, - { type: 'unknown-type', id: 'some-id-2' }, - ]; - - const options = { namespace: 'some-ns' }; - await expect(wrapper.bulkGet(bulkGetParams, options)).resolves.toEqual({ - ...mockedResponse, - saved_objects: [ - { - ...mockedResponse.saved_objects[0], - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - }, - { - ...mockedResponse.saved_objects[1], - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - }, - ], - }); - expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(bulkGetParams, options); - }); - - it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { - const mockedResponse = { - saved_objects: [ - { - id: 'some-id', - type: 'unknown-type', - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - namespaces: ['some-ns'], - references: [], - }, - { - id: 'some-id-2', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - namespaces: ['some-ns'], - references: [], - }, - ], - total: 2, - per_page: 2, - page: 1, - }; - - mockBaseClient.bulkGet.mockResolvedValue(mockedResponse); - - const bulkGetParams = [ - { type: 'unknown-type', id: 'some-id' }, - { type: 'known-type', id: 'some-id-2' }, - ]; - - const options = { namespace: 'some-ns' }; - await expect(wrapper.bulkGet(bulkGetParams, options)).resolves.toEqual({ - ...mockedResponse, - saved_objects: [ - { - ...mockedResponse.saved_objects[0], - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - }, - { - ...mockedResponse.saved_objects[1], - attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, - }, - ], - }); - expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(bulkGetParams, options); - - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( - 1 - ); - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id-2', namespace: 'some-ns' }, - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - undefined, - { user: mockAuthenticatedUser() } - ); - }); - - it('includes both attributes and error if decryption fails.', async () => { - const mockedResponse = { - saved_objects: [ - { - id: 'some-id', - type: 'unknown-type', - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - namespaces: ['some-ns'], - references: [], - }, - { - id: 'some-id-2', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - namespaces: ['some-ns'], - references: [], - }, - ], - total: 2, - per_page: 2, - page: 1, - }; - - mockBaseClient.bulkGet.mockResolvedValue(mockedResponse); - - const decryptionError = new EncryptionError( - 'something failed', - 'attrNotSoSecret', - EncryptionErrorOperation.Decryption - ); - encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ - attributes: { attrOne: 'one', attrThree: 'three' }, - error: decryptionError, - }); - - const bulkGetParams = [ - { type: 'unknown-type', id: 'some-id' }, - { type: 'known-type', id: 'some-id-2' }, - ]; - - const options = { namespace: 'some-ns' }; - await expect(wrapper.bulkGet(bulkGetParams, options)).resolves.toEqual({ - ...mockedResponse, - saved_objects: [ - { - ...mockedResponse.saved_objects[0], - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - }, - { - ...mockedResponse.saved_objects[1], - attributes: { attrOne: 'one', attrThree: 'three' }, - error: decryptionError, - }, - ], - }); - expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(bulkGetParams, options); - - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( - 1 - ); - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id-2', namespace: 'some-ns' }, - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - undefined, - { user: mockAuthenticatedUser() } - ); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.bulkGet.mockRejectedValue(failureReason); - - await expect(wrapper.bulkGet([{ type: 'known-type', id: 'some-id' }])).rejects.toThrowError( - failureReason - ); - - expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkGet).toHaveBeenCalledWith( - [{ type: 'known-type', id: 'some-id' }], - undefined - ); - }); - - it('redirects request to underlying base client and return errors result if type is registered', async () => { - const mockedResponse = { - saved_objects: [ - { - id: 'bad', - type: 'known-type', - error: { statusCode: 404, message: 'Not found' }, - }, - ], - total: 1, - per_page: 1, - page: 1, - }; - mockBaseClient.bulkGet.mockResolvedValue(mockedResponse as any); - const bulkGetParams = [{ type: 'known-type', id: 'bad' }]; - - const options = { namespace: 'some-ns' }; - await expect(wrapper.bulkGet(bulkGetParams, options)).resolves.toEqual({ - ...mockedResponse, - saved_objects: [{ ...mockedResponse.saved_objects[0] }], - }); - expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1); - }); -}); - -describe('#get', () => { - it('redirects request to underlying base client and does not alter response if type is not registered', async () => { - const mockedResponse = { - id: 'some-id', - type: 'unknown-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - references: [], - }; - - mockBaseClient.get.mockResolvedValue(mockedResponse); - - const options = { namespace: 'some-ns' }; - await expect(wrapper.get('unknown-type', 'some-id', options)).resolves.toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - }); - expect(mockBaseClient.get).toHaveBeenCalledTimes(1); - expect(mockBaseClient.get).toHaveBeenCalledWith('unknown-type', 'some-id', options); - }); - - it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { - const mockedResponse = { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - references: [], - }; - - mockBaseClient.get.mockResolvedValue(mockedResponse); - - const options = { namespace: 'some-ns' }; - await expect(wrapper.get('known-type', 'some-id', options)).resolves.toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, - }); - expect(mockBaseClient.get).toHaveBeenCalledTimes(1); - expect(mockBaseClient.get).toHaveBeenCalledWith('known-type', 'some-id', options); - - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( - 1 - ); - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - undefined, - { user: mockAuthenticatedUser() } - ); - }); - - it('includes both attributes and error if decryption fails.', async () => { - const mockedResponse = { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - references: [], - }; - - mockBaseClient.get.mockResolvedValue(mockedResponse); - - const decryptionError = new EncryptionError( - 'something failed', - 'attrNotSoSecret', - EncryptionErrorOperation.Decryption - ); - encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ - attributes: { attrOne: 'one', attrThree: 'three' }, - error: decryptionError, - }); - - const options = { namespace: 'some-ns' }; - await expect(wrapper.get('known-type', 'some-id', options)).resolves.toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, - error: decryptionError, - }); - expect(mockBaseClient.get).toHaveBeenCalledTimes(1); - expect(mockBaseClient.get).toHaveBeenCalledWith('known-type', 'some-id', options); - - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( - 1 - ); - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - undefined, - { user: mockAuthenticatedUser() } - ); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.get.mockRejectedValue(failureReason); - - await expect(wrapper.get('known-type', 'some-id')).rejects.toThrowError(failureReason); - - expect(mockBaseClient.get).toHaveBeenCalledTimes(1); - expect(mockBaseClient.get).toHaveBeenCalledWith('known-type', 'some-id', undefined); - }); -}); - -describe('#bulkResolve', () => { - it('redirects request to underlying base client and does not alter response if type is not registered', async () => { - const mockedResponse = { - resolved_objects: [ - { - saved_object: { - id: 'some-id', - type: 'unknown-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - references: [], - }, - }, - { - saved_object: { - id: 'some-id-2', - type: 'unknown-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - references: [], - }, - }, - ], - }; - - mockBaseClient.bulkResolve.mockResolvedValue( - mockedResponse as unknown as SavedObjectsBulkResolveResponse - ); - - const bulkResolveParams = [ - { type: 'unknown-type', id: 'some-id' }, - { type: 'unknown-type', id: 'some-id-2' }, - ]; - - const options = { namespace: 'some-ns' }; - await expect(wrapper.bulkResolve(bulkResolveParams, options)).resolves.toEqual(mockedResponse); - expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith(bulkResolveParams, options); - }); - - it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { - const mockedResponse = { - resolved_objects: [ - { - saved_object: { - id: 'some-id', - type: 'unknown-type', - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - namespaces: ['some-ns'], - references: [], - }, - }, - { - saved_object: { - id: 'some-id-2', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - namespaces: ['some-ns'], - references: [], - }, - }, - ], - }; - - mockBaseClient.bulkResolve.mockResolvedValue( - mockedResponse as unknown as SavedObjectsBulkResolveResponse - ); - - const bulkResolveParams = [ - { type: 'unknown-type', id: 'some-id' }, - { type: 'known-type', id: 'some-id-2' }, - ]; - - const options = { namespace: 'some-ns' }; - await expect(wrapper.bulkResolve(bulkResolveParams, options)).resolves.toEqual({ - resolved_objects: [ - mockedResponse.resolved_objects[0], - { - saved_object: { - ...mockedResponse.resolved_objects[1].saved_object, - attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, - }, - }, - ], - }); - expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith(bulkResolveParams, options); - - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( - 1 - ); - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id-2', namespace: 'some-ns' }, - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - undefined, - { user: mockAuthenticatedUser() } - ); - }); - - it('includes both attributes and error if decryption fails.', async () => { - const mockedResponse = { - resolved_objects: [ - { - saved_object: { - id: 'some-id', - type: 'unknown-type', - attributes: { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - namespaces: ['some-ns'], - references: [], - }, - }, - { - saved_object: { - id: 'some-id-2', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - namespaces: ['some-ns'], - references: [], - }, - }, - ], - }; - - mockBaseClient.bulkResolve.mockResolvedValue( - mockedResponse as unknown as SavedObjectsBulkResolveResponse - ); - - const decryptionError = new EncryptionError( - 'something failed', - 'attrNotSoSecret', - EncryptionErrorOperation.Decryption - ); - encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ - attributes: { attrOne: 'one', attrThree: 'three' }, - error: decryptionError, - }); - - const bulkResolveParams = [ - { type: 'unknown-type', id: 'some-id' }, - { type: 'known-type', id: 'some-id-2' }, - ]; - - const options = { namespace: 'some-ns' }; - await expect(wrapper.bulkResolve(bulkResolveParams, options)).resolves.toEqual({ - resolved_objects: [ - mockedResponse.resolved_objects[0], - { - saved_object: { - ...mockedResponse.resolved_objects[1].saved_object, - attributes: { attrOne: 'one', attrThree: 'three' }, - error: decryptionError, - }, - }, - ], - }); - expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith(bulkResolveParams, options); - - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( - 1 - ); - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id-2', namespace: 'some-ns' }, - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - undefined, - { user: mockAuthenticatedUser() } - ); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.bulkResolve.mockRejectedValue(failureReason); - - await expect(wrapper.bulkResolve([{ type: 'known-type', id: 'some-id' }])).rejects.toThrowError( - failureReason - ); - - expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith( - [{ type: 'known-type', id: 'some-id' }], - undefined - ); - }); - - it('redirects request to underlying base client and return errors result if type is registered', async () => { - const mockedResponse = { - resolved_objects: [ - { - saved_object: { - id: 'bad', - type: 'known-type', - error: { statusCode: 404, message: 'Not found' }, - }, - }, - ], - }; - mockBaseClient.bulkResolve.mockResolvedValue( - mockedResponse as unknown as SavedObjectsBulkResolveResponse - ); - const bulkGetParams = [{ type: 'known-type', id: 'bad' }]; - - const options = { namespace: 'some-ns' }; - await expect(wrapper.bulkResolve(bulkGetParams, options)).resolves.toEqual(mockedResponse); - expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1); - }); -}); - -describe('#resolve', () => { - it('redirects request to underlying base client and does not alter response if type is not registered', async () => { - const mockedResponse = { - saved_object: { - id: 'some-id', - type: 'unknown-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - references: [], - }, - outcome: 'exactMatch' as 'exactMatch', - }; - - mockBaseClient.resolve.mockResolvedValue(mockedResponse); - - const options = { namespace: 'some-ns' }; - await expect(wrapper.resolve('unknown-type', 'some-id', options)).resolves.toEqual( - mockedResponse - ); - expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); - expect(mockBaseClient.resolve).toHaveBeenCalledWith('unknown-type', 'some-id', options); - }); - - it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { - const mockedResponse = { - saved_object: { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - references: [], - }, - outcome: 'exactMatch' as 'exactMatch', - }; - - mockBaseClient.resolve.mockResolvedValue(mockedResponse); - - const options = { namespace: 'some-ns' }; - await expect(wrapper.resolve('known-type', 'some-id', options)).resolves.toEqual({ - ...mockedResponse, - saved_object: { - ...mockedResponse.saved_object, - attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, - }, - }); - expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); - expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', options); - - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( - 1 - ); - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - undefined, - { user: mockAuthenticatedUser() } - ); - }); - - it('includes both attributes and error with modified outcome if decryption fails.', async () => { - const mockedResponse = { - saved_object: { - id: 'some-id', - type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - references: [], - }, - outcome: 'exactMatch' as 'exactMatch', - }; - - mockBaseClient.resolve.mockResolvedValue(mockedResponse); - - const decryptionError = new EncryptionError( - 'something failed', - 'attrNotSoSecret', - EncryptionErrorOperation.Decryption - ); - encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ - attributes: { attrOne: 'one', attrThree: 'three' }, - error: decryptionError, - }); - - const options = { namespace: 'some-ns' }; - await expect(wrapper.resolve('known-type', 'some-id', options)).resolves.toEqual({ - ...mockedResponse, - saved_object: { - ...mockedResponse.saved_object, - attributes: { attrOne: 'one', attrThree: 'three' }, - error: decryptionError, - }, - }); - expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); - expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', options); - - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( - 1 - ); - expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - undefined, - { user: mockAuthenticatedUser() } - ); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.resolve.mockRejectedValue(failureReason); - - await expect(wrapper.resolve('known-type', 'some-id')).rejects.toThrowError(failureReason); - - expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); - expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', undefined); - }); -}); - -describe('#update', () => { - it('redirects request to underlying base client if type is not registered', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { version: 'some-version' }; - const mockedResponse = { id: 'some-id', type: 'unknown-type', attributes, references: [] }; - - mockBaseClient.update.mockResolvedValue(mockedResponse); - - await expect(wrapper.update('unknown-type', 'some-id', attributes, options)).resolves.toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - }); - expect(mockBaseClient.update).toHaveBeenCalledTimes(1); - expect(mockBaseClient.update).toHaveBeenCalledWith( - 'unknown-type', - 'some-id', - attributes, - options - ); - }); - - it('encrypts attributes and strips them from response except for ones with `dangerouslyExposeValue` set to `true`', async () => { - const attributes = { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }; - const options = { version: 'some-version' }; - const mockedResponse = { - id: 'some-id', - type: 'known-type', - attributes: { - ...attributes, - attrSecret: `*${attributes.attrSecret}*`, - attrNotSoSecret: `*${attributes.attrNotSoSecret}*`, - }, - references: [], - }; - - mockBaseClient.update.mockResolvedValue(mockedResponse); - - await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id' }, - { - attrOne: 'one', - attrSecret: 'secret', - attrNotSoSecret: 'not-so-secret', - attrThree: 'three', - }, - { user: mockAuthenticatedUser() } - ); - - expect(mockBaseClient.update).toHaveBeenCalledTimes(1); - expect(mockBaseClient.update).toHaveBeenCalledWith( - 'known-type', - 'some-id', - { - attrOne: 'one', - attrSecret: '*secret*', - attrNotSoSecret: '*not-so-secret*', - attrThree: 'three', - }, - options - ); - }); - - describe('namespace', () => { - const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { version: 'some-version', namespace }; - const mockedResponse = { id: 'some-id', type: 'known-type', attributes, references: [] }; - - mockBaseClient.update.mockResolvedValue(mockedResponse); - - await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { - type: 'known-type', - id: 'some-id', - namespace: expectNamespaceInDescriptor ? namespace : undefined, - }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, - { user: mockAuthenticatedUser() } - ); - - expect(mockBaseClient.update).toHaveBeenCalledTimes(1); - expect(mockBaseClient.update).toHaveBeenCalledWith( - 'known-type', - 'some-id', - { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - options - ); - }; - - it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => { - await doTest('some-namespace', true); - }); - - it('does not use `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { - mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); - await doTest('some-namespace', false); - }); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.update.mockRejectedValue(failureReason); - - await expect( - wrapper.update('known-type', 'some-id', { - attrOne: 'one', - attrSecret: 'secret', - attrThree: 'three', - }) - ).rejects.toThrowError(failureReason); - - expect(mockBaseClient.update).toHaveBeenCalledTimes(1); - expect(mockBaseClient.update).toHaveBeenCalledWith( - 'known-type', - 'some-id', - { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - undefined - ); - }); -}); - -describe('#removeReferencesTo', () => { - it('redirects request to underlying base client', async () => { - const options = { namespace: 'some-ns' }; - - await wrapper.removeReferencesTo('some-type', 'some-id', options); - - expect(mockBaseClient.removeReferencesTo).toHaveBeenCalledTimes(1); - expect(mockBaseClient.removeReferencesTo).toHaveBeenCalledWith('some-type', 'some-id', options); - }); - - it('returns response from underlying client', async () => { - const returnValue = { - updated: 12, - }; - mockBaseClient.removeReferencesTo.mockResolvedValue(returnValue); - - const result = await wrapper.removeReferencesTo('known-type', 'some-id'); - - expect(result).toBe(returnValue); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.removeReferencesTo.mockRejectedValue(failureReason); - - await expect(wrapper.removeReferencesTo('known-type', 'some-id')).rejects.toThrowError( - failureReason - ); - - expect(mockBaseClient.removeReferencesTo).toHaveBeenCalledTimes(1); - }); -}); - -describe('#openPointInTimeForType', () => { - it('redirects request to underlying base client', async () => { - const options = { keepAlive: '1m' }; - - await wrapper.openPointInTimeForType('some-type', options); - - expect(mockBaseClient.openPointInTimeForType).toHaveBeenCalledTimes(1); - expect(mockBaseClient.openPointInTimeForType).toHaveBeenCalledWith('some-type', options); - }); - - it('returns response from underlying client', async () => { - const returnValue = { - id: 'abc123', - }; - mockBaseClient.openPointInTimeForType.mockResolvedValue(returnValue); - - const result = await wrapper.openPointInTimeForType('known-type'); - - expect(result).toBe(returnValue); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.openPointInTimeForType.mockRejectedValue(failureReason); - - await expect(wrapper.openPointInTimeForType('known-type')).rejects.toThrowError(failureReason); - - expect(mockBaseClient.openPointInTimeForType).toHaveBeenCalledTimes(1); - }); -}); - -describe('#closePointInTime', () => { - it('redirects request to underlying base client', async () => { - const id = 'abc123'; - await wrapper.closePointInTime(id); - - expect(mockBaseClient.closePointInTime).toHaveBeenCalledTimes(1); - expect(mockBaseClient.closePointInTime).toHaveBeenCalledWith(id, undefined); - }); - - it('returns response from underlying client', async () => { - const returnValue = { - succeeded: true, - num_freed: 1, - }; - mockBaseClient.closePointInTime.mockResolvedValue(returnValue); - - const result = await wrapper.closePointInTime('abc123'); - - expect(result).toBe(returnValue); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.closePointInTime.mockRejectedValue(failureReason); - - await expect(wrapper.closePointInTime('abc123')).rejects.toThrowError(failureReason); - - expect(mockBaseClient.closePointInTime).toHaveBeenCalledTimes(1); - }); - - describe('#collectMultiNamespaceReferences', () => { - it('redirects request to underlying base client', async () => { - const objects = [{ type: 'foo', id: 'bar' }]; - const options = { namespace: 'some-ns' }; - await wrapper.collectMultiNamespaceReferences(objects, options); - - expect(mockBaseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); - expect(mockBaseClient.collectMultiNamespaceReferences).toHaveBeenCalledWith(objects, options); - }); - - it('returns response from underlying client', async () => { - const returnValue = { objects: [] }; - mockBaseClient.collectMultiNamespaceReferences.mockResolvedValue(returnValue); - - const objects = [{ type: 'foo', id: 'bar' }]; - const result = await wrapper.collectMultiNamespaceReferences(objects); - - expect(result).toBe(returnValue); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.collectMultiNamespaceReferences.mockRejectedValue(failureReason); - - const objects = [{ type: 'foo', id: 'bar' }]; - await expect(wrapper.collectMultiNamespaceReferences(objects)).rejects.toThrowError( - failureReason - ); - - expect(mockBaseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); - }); - }); - - describe('#updateObjectsSpaces', () => { - const objects = [{ type: 'foo', id: 'bar' }]; - const spacesToAdd = ['space-x']; - const spacesToRemove = ['space-y']; - const options = {}; - it('redirects request to underlying base client', async () => { - await wrapper.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options); - - expect(mockBaseClient.updateObjectsSpaces).toHaveBeenCalledTimes(1); - expect(mockBaseClient.updateObjectsSpaces).toHaveBeenCalledWith( - objects, - spacesToAdd, - spacesToRemove, - options - ); - }); - - it('returns response from underlying client', async () => { - const returnValue = { objects: [] }; - mockBaseClient.updateObjectsSpaces.mockResolvedValue(returnValue); - - const result = await wrapper.updateObjectsSpaces( - objects, - spacesToAdd, - spacesToRemove, - options - ); - - expect(result).toBe(returnValue); - }); - - it('fails if base client fails', async () => { - const failureReason = new Error('Something bad happened...'); - mockBaseClient.updateObjectsSpaces.mockRejectedValue(failureReason); - - await expect( - wrapper.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options) - ).rejects.toThrowError(failureReason); - - expect(mockBaseClient.updateObjectsSpaces).toHaveBeenCalledTimes(1); - }); - }); -}); - -describe('#createPointInTimeFinder', () => { - it('redirects request to underlying base client with default dependencies', () => { - const options = { type: ['a', 'b'], search: 'query' }; - wrapper.createPointInTimeFinder(options); - - expect(mockBaseClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); - expect(mockBaseClient.createPointInTimeFinder).toHaveBeenCalledWith(options, { - client: wrapper, - }); - }); - - it('redirects request to underlying base client with custom dependencies', () => { - const options = { type: ['a', 'b'], search: 'query' }; - const dependencies = { - client: { - find: jest.fn(), - openPointInTimeForType: jest.fn(), - closePointInTime: jest.fn(), - }, - }; - wrapper.createPointInTimeFinder(options, dependencies); - - expect(mockBaseClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); - expect(mockBaseClient.createPointInTimeFinder).toHaveBeenCalledWith(options, dependencies); - }); -}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts deleted file mode 100644 index e2fcfd2a6ef25..0000000000000 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ /dev/null @@ -1,399 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { - ISavedObjectTypeRegistry, - SavedObject, - SavedObjectsBaseOptions, - SavedObjectsBulkCreateObject, - SavedObjectsBulkDeleteObject, - SavedObjectsBulkDeleteOptions, - SavedObjectsBulkGetObject, - SavedObjectsBulkResolveObject, - SavedObjectsBulkResponse, - SavedObjectsBulkUpdateObject, - SavedObjectsBulkUpdateResponse, - SavedObjectsCheckConflictsObject, - SavedObjectsClientContract, - SavedObjectsClosePointInTimeOptions, - SavedObjectsCollectMultiNamespaceReferencesObject, - SavedObjectsCollectMultiNamespaceReferencesOptions, - SavedObjectsCollectMultiNamespaceReferencesResponse, - SavedObjectsCreateOptions, - SavedObjectsCreatePointInTimeFinderDependencies, - SavedObjectsCreatePointInTimeFinderOptions, - SavedObjectsFindOptions, - SavedObjectsFindResponse, - SavedObjectsOpenPointInTimeOptions, - SavedObjectsRemoveReferencesToOptions, - SavedObjectsRemoveReferencesToResponse, - SavedObjectsUpdateObjectsSpacesObject, - SavedObjectsUpdateObjectsSpacesOptions, - SavedObjectsUpdateOptions, - SavedObjectsUpdateResponse, -} from '@kbn/core/server'; -import { SavedObjectsErrorHelpers, SavedObjectsUtils } from '@kbn/core/server'; -import type { AuthenticatedUser } from '@kbn/security-plugin/common/model'; - -import type { EncryptedSavedObjectsService } from '../crypto'; -import { getDescriptorNamespace } from './get_descriptor_namespace'; - -interface EncryptedSavedObjectsClientOptions { - baseClient: SavedObjectsClientContract; - baseTypeRegistry: ISavedObjectTypeRegistry; - service: Readonly; - getCurrentUser: () => AuthenticatedUser | undefined; -} - -export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientContract { - constructor( - private readonly options: EncryptedSavedObjectsClientOptions, - public readonly errors = SavedObjectsErrorHelpers - ) {} - - public async checkConflicts( - objects: SavedObjectsCheckConflictsObject[] = [], - options?: SavedObjectsBaseOptions - ) { - return await this.options.baseClient.checkConflicts(objects, options); - } - - public async create( - type: string, - attributes: T = {} as T, - options: SavedObjectsCreateOptions = {} - ) { - if (!this.options.service.isRegistered(type)) { - return await this.options.baseClient.create(type, attributes, options); - } - - const id = this.getValidId(options.id, options.version, options.overwrite); - const namespace = getDescriptorNamespace( - this.options.baseTypeRegistry, - type, - options.namespace - ); - return await this.handleEncryptedAttributesInResponse( - await this.options.baseClient.create( - type, - (await this.options.service.encryptAttributes( - { type, id, namespace }, - attributes as Record, - { user: this.options.getCurrentUser() } - )) as T, - { ...options, id } - ), - attributes, - namespace - ); - } - - public async bulkCreate( - objects: Array>, - options?: SavedObjectsBaseOptions & Pick - ) { - // We encrypt attributes for every object in parallel and that can potentially exhaust libuv or - // NodeJS thread pool. If it turns out to be a problem, we can consider switching to the - // sequential processing. - const encryptedObjects = await Promise.all( - objects.map(async (object) => { - if (!this.options.service.isRegistered(object.type)) { - return object; - } - - const id = this.getValidId(object.id, object.version, options?.overwrite); - const namespace = getDescriptorNamespace( - this.options.baseTypeRegistry, - object.type, - options?.namespace - ); - return { - ...object, - id, - attributes: await this.options.service.encryptAttributes( - { type: object.type, id, namespace }, - object.attributes as Record, - { user: this.options.getCurrentUser() } - ), - } as SavedObjectsBulkCreateObject; - }) - ); - - return await this.handleEncryptedAttributesInBulkResponse( - await this.options.baseClient.bulkCreate(encryptedObjects, options), - objects - ); - } - - public async bulkUpdate( - objects: Array>, - options?: SavedObjectsBaseOptions - ) { - // We encrypt attributes for every object in parallel and that can potentially exhaust libuv or - // NodeJS thread pool. If it turns out to be a problem, we can consider switching to the - // sequential processing. - const encryptedObjects = await Promise.all( - objects.map(async (object) => { - const { type, id, attributes, namespace: objectNamespace } = object; - if (!this.options.service.isRegistered(type)) { - return object; - } - const namespace = getDescriptorNamespace( - this.options.baseTypeRegistry, - type, - objectNamespace ?? options?.namespace - ); - return { - ...object, - attributes: await this.options.service.encryptAttributes( - { type, id, namespace }, - attributes, - { user: this.options.getCurrentUser() } - ), - }; - }) - ); - - return await this.handleEncryptedAttributesInBulkResponse( - await this.options.baseClient.bulkUpdate(encryptedObjects, options), - objects - ); - } - - public async delete(type: string, id: string, options?: SavedObjectsBaseOptions) { - return await this.options.baseClient.delete(type, id, options); - } - - public async bulkDelete( - objects: SavedObjectsBulkDeleteObject[], - options?: SavedObjectsBulkDeleteOptions - ) { - return await this.options.baseClient.bulkDelete(objects, options); - } - - public async find(options: SavedObjectsFindOptions) { - return await this.handleEncryptedAttributesInBulkResponse( - await this.options.baseClient.find(options), - undefined - ); - } - - public async bulkGet( - objects: SavedObjectsBulkGetObject[] = [], - options?: SavedObjectsBaseOptions - ) { - return await this.handleEncryptedAttributesInBulkResponse( - await this.options.baseClient.bulkGet(objects, options), - undefined - ); - } - - public async get(type: string, id: string, options?: SavedObjectsBaseOptions) { - return await this.handleEncryptedAttributesInResponse( - await this.options.baseClient.get(type, id, options), - undefined as unknown, - getDescriptorNamespace(this.options.baseTypeRegistry, type, options?.namespace) - ); - } - - public async bulkResolve( - objects: SavedObjectsBulkResolveObject[], - options?: SavedObjectsBaseOptions - ) { - const bulkResolveResult = await this.options.baseClient.bulkResolve(objects, options); - - for (const resolved of bulkResolveResult.resolved_objects) { - const savedObject = resolved.saved_object; - await this.handleEncryptedAttributesInResponse( - savedObject, - undefined as unknown, - getDescriptorNamespace( - this.options.baseTypeRegistry, - savedObject.type, - savedObject.namespaces ? savedObject.namespaces[0] : undefined - ) - ); - } - - return bulkResolveResult; - } - - public async resolve(type: string, id: string, options?: SavedObjectsBaseOptions) { - const resolveResult = await this.options.baseClient.resolve(type, id, options); - const object = await this.handleEncryptedAttributesInResponse( - resolveResult.saved_object, - undefined as unknown, - getDescriptorNamespace(this.options.baseTypeRegistry, type, options?.namespace) - ); - return { - ...resolveResult, - saved_object: object, - }; - } - - public async update( - type: string, - id: string, - attributes: Partial, - options?: SavedObjectsUpdateOptions - ) { - if (!this.options.service.isRegistered(type)) { - return await this.options.baseClient.update(type, id, attributes, options); - } - const namespace = getDescriptorNamespace( - this.options.baseTypeRegistry, - type, - options?.namespace - ); - return this.handleEncryptedAttributesInResponse( - await this.options.baseClient.update( - type, - id, - await this.options.service.encryptAttributes({ type, id, namespace }, attributes, { - user: this.options.getCurrentUser(), - }), - options - ), - attributes, - namespace - ); - } - - public async removeReferencesTo( - type: string, - id: string, - options: SavedObjectsRemoveReferencesToOptions = {} - ): Promise { - return await this.options.baseClient.removeReferencesTo(type, id, options); - } - - public async openPointInTimeForType( - type: string | string[], - options: SavedObjectsOpenPointInTimeOptions = {} - ) { - return await this.options.baseClient.openPointInTimeForType(type, options); - } - - public async closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions) { - return await this.options.baseClient.closePointInTime(id, options); - } - - public createPointInTimeFinder( - findOptions: SavedObjectsCreatePointInTimeFinderOptions, - dependencies?: SavedObjectsCreatePointInTimeFinderDependencies - ) { - return this.options.baseClient.createPointInTimeFinder(findOptions, { - client: this, - // Include dependencies last so that subsequent SO client wrappers have their settings applied. - ...dependencies, - }); - } - - public async collectMultiNamespaceReferences( - objects: SavedObjectsCollectMultiNamespaceReferencesObject[], - options?: SavedObjectsCollectMultiNamespaceReferencesOptions - ): Promise { - return await this.options.baseClient.collectMultiNamespaceReferences(objects, options); - } - - public async updateObjectsSpaces( - objects: SavedObjectsUpdateObjectsSpacesObject[], - spacesToAdd: string[], - spacesToRemove: string[], - options?: SavedObjectsUpdateObjectsSpacesOptions - ) { - return await this.options.baseClient.updateObjectsSpaces( - objects, - spacesToAdd, - spacesToRemove, - options - ); - } - - /** - * Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't - * registered, response is returned as is. - * @param response Raw response returned by the underlying base client. - * @param [originalAttributes] Optional list of original attributes of the saved object. - * @param [namespace] Optional namespace that was used for the saved objects operation. - */ - private async handleEncryptedAttributesInResponse< - T, - R extends SavedObjectsUpdateResponse | SavedObject - >(response: R, originalAttributes?: T, namespace?: string): Promise { - if (response.attributes && this.options.service.isRegistered(response.type)) { - // Error is returned when decryption fails, and in this case encrypted attributes will be - // stripped from the returned attributes collection. That will let consumer decide whether to - // fail or handle recovery gracefully. - const { attributes, error } = await this.options.service.stripOrDecryptAttributes( - { id: response.id, type: response.type, namespace }, - response.attributes as Record, - originalAttributes as Record, - { user: this.options.getCurrentUser() } - ); - - response.attributes = attributes as T; - if (error) { - response.error = error as any; - } - } - - return response; - } - - /** - * Strips encrypted attributes from any bulk Saved Objects API response. If type for any bulk - * response portion isn't registered, it is returned as is. - * @param response Raw response returned by the underlying base client. - * @param [objects] Optional list of saved objects with original attributes. - */ - private async handleEncryptedAttributesInBulkResponse< - T, - R extends - | SavedObjectsBulkResponse - | SavedObjectsFindResponse - | SavedObjectsBulkUpdateResponse, - O extends Array> | Array> - >(response: R, objects?: O) { - for (const [index, savedObject] of response.saved_objects.entries()) { - await this.handleEncryptedAttributesInResponse( - savedObject, - objects?.[index].attributes ?? undefined, - getDescriptorNamespace( - this.options.baseTypeRegistry, - savedObject.type, - savedObject.namespaces ? savedObject.namespaces[0] : undefined - ) - ); - } - - return response; - } - - // Saved objects with encrypted attributes should have IDs that are hard to guess especially - // since IDs are part of the AAD used during encryption, that's why we control them within this - // wrapper and don't allow consumers to specify their own IDs directly unless overwriting the original document. - private getValidId( - id: string | undefined, - version: string | undefined, - overwrite: boolean | undefined - ) { - if (id) { - // only allow a specified ID if we're overwriting an existing ESO with a Version - // this helps us ensure that the document really was previously created using ESO - // and not being used to get around the specified ID limitation - const canSpecifyID = (overwrite && version) || SavedObjectsUtils.isRandomId(id); - if (!canSpecifyID) { - throw this.errors.createBadRequestError( - 'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.' - ); - } - return id; - } - return SavedObjectsUtils.generateId(); - } -} diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts index b93141a3ad989..dc2afe6e41957 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts @@ -12,8 +12,6 @@ import type { } from '@kbn/core/server'; import { coreMock, - httpServerMock, - savedObjectsClientMock, savedObjectsRepositoryMock, savedObjectsTypeRegistryMock, } from '@kbn/core/server/mocks'; @@ -23,7 +21,6 @@ import type { ClientInstanciator } from '.'; import { setupSavedObjects } from '.'; import type { EncryptedSavedObjectsService } from '../crypto'; import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock'; -import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; describe('#setupSavedObjects', () => { let setupContract: ClientInstanciator; @@ -55,42 +52,6 @@ describe('#setupSavedObjects', () => { }); }); - it('properly registers client wrapper factory', () => { - expect(coreSetupMock.savedObjects.addClientWrapper).toHaveBeenCalledTimes(1); - expect(coreSetupMock.savedObjects.addClientWrapper).toHaveBeenCalledWith( - Number.MAX_SAFE_INTEGER, - 'encryptedSavedObjects', - expect.any(Function) - ); - - const [[, , clientFactory]] = coreSetupMock.savedObjects.addClientWrapper.mock.calls; - expect( - clientFactory({ - client: savedObjectsClientMock.create(), - typeRegistry: savedObjectsTypeRegistryMock.create(), - request: httpServerMock.createKibanaRequest(), - }) - ).toBeInstanceOf(EncryptedSavedObjectsClientWrapper); - }); - - it('properly registers client wrapper factory with', () => { - expect(coreSetupMock.savedObjects.addClientWrapper).toHaveBeenCalledTimes(1); - expect(coreSetupMock.savedObjects.addClientWrapper).toHaveBeenCalledWith( - Number.MAX_SAFE_INTEGER, - 'encryptedSavedObjects', - expect.any(Function) - ); - - const [[, , clientFactory]] = coreSetupMock.savedObjects.addClientWrapper.mock.calls; - expect( - clientFactory({ - client: savedObjectsClientMock.create(), - typeRegistry: savedObjectsTypeRegistryMock.create(), - request: httpServerMock.createKibanaRequest(), - }) - ).toBeInstanceOf(EncryptedSavedObjectsClientWrapper); - }); - describe('#setupContract', () => { it('includes hiddenTypes when specified', async () => { await setupContract({ includedHiddenTypes: ['hiddenType'] }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index e2b58e3003d96..6be6fae9f5d31 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -22,8 +22,8 @@ import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; import type { EncryptedSavedObjectsService } from '../crypto'; -import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; import { getDescriptorNamespace, normalizeNamespace } from './get_descriptor_namespace'; +import { SavedObjectsEncryptionExtension } from './saved_objects_encryption_extension'; export { normalizeNamespace }; @@ -81,22 +81,15 @@ export function setupSavedObjects({ security, getStartServices, }: SetupSavedObjectsParams): ClientInstanciator { - // Register custom saved object client that will encrypt, decrypt and strip saved object - // attributes where appropriate for any saved object repository request. We choose max possible - // priority for this wrapper to allow all other wrappers to set proper `namespace` for the Saved - // Object (e.g. wrapper registered by the Spaces plugin) before we encrypt attributes since - // `namespace` is included into AAD. - savedObjects.addClientWrapper( - Number.MAX_SAFE_INTEGER, - 'encryptedSavedObjects', - ({ client: baseClient, typeRegistry: baseTypeRegistry, request }) => - new EncryptedSavedObjectsClientWrapper({ - baseClient, - baseTypeRegistry, - service, - getCurrentUser: () => security?.authc.getCurrentUser(request) ?? undefined, - }) - ); + // Register custom saved object extension that will encrypt, decrypt and strip saved object + // attributes where appropriate for any saved object repository request. + savedObjects.setEncryptionExtension(({ typeRegistry: baseTypeRegistry, request }) => { + return new SavedObjectsEncryptionExtension({ + baseTypeRegistry, + service, + getCurrentUser: () => security?.authc.getCurrentUser(request) ?? undefined, + }); + }); return (clientOpts) => { const internalRepositoryAndTypeRegistryPromise = getStartServices().then( diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.test.mocks.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.test.mocks.ts new file mode 100644 index 0000000000000..d0d5262c1b83a --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.test.mocks.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { getDescriptorNamespace } from './get_descriptor_namespace'; + +export const mockGetDescriptorNamespace = jest.fn() as jest.MockedFunction< + typeof getDescriptorNamespace +>; + +jest.mock('./get_descriptor_namespace', () => { + return { + getDescriptorNamespace: mockGetDescriptorNamespace, + }; +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.test.ts new file mode 100644 index 0000000000000..aa958c8c9d1d9 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.test.ts @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockGetDescriptorNamespace } from './saved_objects_encryption_extension.test.mocks'; + +import { savedObjectsTypeRegistryMock } from '@kbn/core/server/mocks'; + +import { EncryptionError } from '../crypto'; +import { encryptedSavedObjectsServiceMock } from '../crypto/encrypted_saved_objects_service.mocks'; +import { EncryptionErrorOperation } from '../crypto/encryption_error'; +import { SavedObjectsEncryptionExtension } from './saved_objects_encryption_extension'; + +const KNOWN_TYPE = 'known-type'; +const ATTRIBUTE_TO_STRIP = 'attrSecret'; +const ATTRIBUTE_TO_DECRYPT = 'attrNotSoSecret'; +const DESCRIPTOR_NAMESPACE = 'descriptor-namespace'; +const CURRENT_USER = 'current-user'; + +beforeAll(() => { + // Mock the getDescriptorNamespace result so we don't exercise that functionality in these unit tests + mockGetDescriptorNamespace.mockReturnValue(DESCRIPTOR_NAMESPACE); +}); + +function setup() { + const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); + const mockService = encryptedSavedObjectsServiceMock.createWithTypes([ + { + type: KNOWN_TYPE, + attributesToEncrypt: new Set([ + ATTRIBUTE_TO_STRIP, + { key: ATTRIBUTE_TO_DECRYPT, dangerouslyExposeValue: true }, + ]), + }, + ]); + return { + extension: new SavedObjectsEncryptionExtension({ + baseTypeRegistry: mockBaseTypeRegistry, + service: mockService, + getCurrentUser: jest.fn().mockReturnValue(CURRENT_USER), + }), + service: mockService, + }; +} + +describe('#isEncryptableType', () => { + test('returns true for known types', () => { + const { extension } = setup(); + expect(extension.isEncryptableType(KNOWN_TYPE)).toBe(true); + }); + + test('returns false for unknown types', () => { + const { extension } = setup(); + expect(extension.isEncryptableType('unknown-type')).toBe(false); + }); +}); + +describe('#decryptOrStripResponseAttributes', () => { + const unregisteredSO = { + id: 'some-id', + type: 'unknown-type', + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + score: 1, + references: [], + }; + + const registeredSO = { + id: 'some-id-2', + type: KNOWN_TYPE, + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + score: 1, + references: [], + }; + + test('does not alter response if type is not registered', async () => { + const { extension, service } = setup(); + + await expect(extension.decryptOrStripResponseAttributes(unregisteredSO)).resolves.toEqual({ + ...unregisteredSO, + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + }); + + expect(service.decryptAttributes).not.toHaveBeenCalled(); + }); + + test('strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { + const { extension, service } = setup(); + + await expect(extension.decryptOrStripResponseAttributes(registeredSO)).resolves.toEqual({ + ...registeredSO, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, + }); + + expect(service.stripOrDecryptAttributes).toHaveBeenCalledTimes(1); + }); + + test('includes both attributes and error if decryption fails.', async () => { + const { extension, service } = setup(); + + const decryptionError = new EncryptionError( + 'something failed', + 'attrNotSoSecret', + EncryptionErrorOperation.Decryption + ); + service.stripOrDecryptAttributes.mockResolvedValue({ + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }); + + await expect(extension.decryptOrStripResponseAttributes(unregisteredSO)).resolves.toEqual({ + ...unregisteredSO, + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + }); + + expect(service.stripOrDecryptAttributes).not.toHaveBeenCalled(); + + await expect(extension.decryptOrStripResponseAttributes(registeredSO)).resolves.toEqual({ + ...registeredSO, + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }); + + expect(service.stripOrDecryptAttributes).toHaveBeenCalledTimes(1); + }); +}); + +describe('#encryptAttributes', () => { + test('does not encrypt attributes if type is not registered', async () => { + const { extension, service } = setup(); + + await expect( + extension.encryptAttributes( + { + type: 'unknown-type', + id: 'mock-saved-object-id', + namespace: undefined, + }, + { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + } + ) + ).resolves.toEqual({ + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }); + + expect(service.encryptAttributes).not.toBeCalled(); + }); + + test('encrypts attributes if the type is registered', async () => { + const { extension, service } = setup(); + + await expect( + extension.encryptAttributes( + { + type: KNOWN_TYPE, + id: 'mock-saved-object-id', + namespace: undefined, + }, + { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + } + ) + ).resolves.toEqual({ + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }); + + expect(service.encryptAttributes).toBeCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.ts new file mode 100644 index 0000000000000..32015c8c23036 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/saved_objects_encryption_extension.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObject } from '@kbn/core-saved-objects-common'; +import type { + EncryptedObjectDescriptor, + ISavedObjectsEncryptionExtension, + ISavedObjectTypeRegistry, +} from '@kbn/core-saved-objects-server'; +import type { AuthenticatedUser } from '@kbn/security-plugin/common'; + +import type { EncryptedSavedObjectsService } from '../crypto'; +import { getDescriptorNamespace } from './get_descriptor_namespace'; + +/** + * @internal Only exported for unit testing. + */ +export interface Params { + baseTypeRegistry: ISavedObjectTypeRegistry; + service: Readonly; + getCurrentUser: () => AuthenticatedUser | undefined; +} + +export class SavedObjectsEncryptionExtension implements ISavedObjectsEncryptionExtension { + readonly _baseTypeRegistry: ISavedObjectTypeRegistry; + readonly _service: Readonly; + readonly _getCurrentUser: () => AuthenticatedUser | undefined; + + constructor({ baseTypeRegistry, service, getCurrentUser }: Params) { + this._baseTypeRegistry = baseTypeRegistry; + this._service = service; + this._getCurrentUser = getCurrentUser; + } + + isEncryptableType(type: string) { + return this._service.isRegistered(type); + } + + async decryptOrStripResponseAttributes>( + response: R, + originalAttributes?: T + ): Promise { + if (response.attributes && this._service.isRegistered(response.type)) { + const namespace = response.namespaces ? response.namespaces[0] : undefined; + const normalizedDescriptor = { + id: response.id, + type: response.type, + namespace: getDescriptorNamespace(this._baseTypeRegistry, response.type, namespace), + }; + // Error is returned when decryption fails, and in this case encrypted attributes will be + // stripped from the returned attributes collection. That will let consumer decide whether to + // fail or handle recovery gracefully. + const { attributes, error } = await this._service.stripOrDecryptAttributes( + normalizedDescriptor, + response.attributes as Record, + originalAttributes as Record, + { user: this._getCurrentUser() } + ); + + return { ...response, attributes, ...(error && { error }) }; + } + + return response; + } + + async encryptAttributes>( + descriptor: EncryptedObjectDescriptor, + attributes: T + ): Promise { + if (!this._service.isRegistered(descriptor.type)) { + return attributes; + } + + const { type, id, namespace } = descriptor; + + const normalizedDescriptor = { + type, + id, + namespace: getDescriptorNamespace(this._baseTypeRegistry, type, namespace), + }; + return this._service.encryptAttributes(normalizedDescriptor, attributes, { + user: this._getCurrentUser(), + }); + } +} diff --git a/x-pack/plugins/fleet/server/integration_tests/upgrade_agent_policy_schema_version.test.ts b/x-pack/plugins/fleet/server/integration_tests/upgrade_agent_policy_schema_version.test.ts index 39c3cee2fdeb1..52367311cd48a 100644 --- a/x-pack/plugins/fleet/server/integration_tests/upgrade_agent_policy_schema_version.test.ts +++ b/x-pack/plugins/fleet/server/integration_tests/upgrade_agent_policy_schema_version.test.ts @@ -15,6 +15,8 @@ import type { import * as kbnTestServer from '@kbn/core/test_helpers/kbn_server'; import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; + import { AGENT_POLICY_SAVED_OBJECT_TYPE, FLEET_AGENT_POLICIES_SCHEMA_VERSION } from '../constants'; import { upgradeAgentPolicySchemaVersion } from '../services/setup/upgrade_agent_policy_schema_version'; import { AGENT_POLICY_INDEX } from '../../common'; @@ -120,7 +122,7 @@ describe('upgrade agent policy schema version', () => { beforeAll(async () => { soClient = kbnServer.coreStart.savedObjects.getScopedClient(fakeRequest, { - excludedWrappers: ['security'], + excludedExtensions: [SECURITY_EXTENSION_ID], }); esClient = kbnServer.coreStart.elasticsearch.client.asInternalUser; }); diff --git a/x-pack/plugins/fleet/server/integration_tests/upgrade_package_install_version.test.ts b/x-pack/plugins/fleet/server/integration_tests/upgrade_package_install_version.test.ts index 4ec403ab4a318..8c9cbeb27fc75 100644 --- a/x-pack/plugins/fleet/server/integration_tests/upgrade_package_install_version.test.ts +++ b/x-pack/plugins/fleet/server/integration_tests/upgrade_package_install_version.test.ts @@ -12,6 +12,8 @@ import { loggerMock } from '@kbn/logging-mocks'; import * as kbnTestServer from '@kbn/core/test_helpers/kbn_server'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; + import { upgradePackageInstallVersion } from '../services/setup/upgrade_package_install_version'; import { FLEET_INSTALL_FORMAT_VERSION, @@ -154,7 +156,7 @@ describe('Uprade package install version', () => { beforeAll(async () => { soClient = kbnServer.coreStart.savedObjects.getScopedClient(fakeRequest, { - excludedWrappers: ['security'], + excludedExtensions: [SECURITY_EXTENSION_ID], }); const res = await soClient.find({ diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index e08bd0161e2b0..58043a2d3203b 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -48,6 +48,8 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/server'; import type { SavedObjectTaggingStart } from '@kbn/saved-objects-tagging-plugin/server'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; + import type { FleetConfigType } from '../common/types'; import type { FleetAuthz } from '../common'; import type { ExperimentalFeatures } from '../common/experimental_features'; @@ -356,7 +358,7 @@ export class FleetPlugin get internalSoClient() { return appContextService .getSavedObjects() - .getScopedClient(request, { excludedWrappers: ['security'] }); + .getScopedClient(request, { excludedExtensions: [SECURITY_EXTENSION_ID] }); }, }, get spaceId() { diff --git a/x-pack/plugins/fleet/server/services/app_context.ts b/x-pack/plugins/fleet/server/services/app_context.ts index 3257f3e969ec8..adcee89605274 100644 --- a/x-pack/plugins/fleet/server/services/app_context.ts +++ b/x-pack/plugins/fleet/server/services/app_context.ts @@ -8,12 +8,12 @@ import type { Observable } from 'rxjs'; import { BehaviorSubject } from 'rxjs'; import { kibanaPackageJson } from '@kbn/utils'; -import type { KibanaRequest } from '@kbn/core/server'; import type { ElasticsearchClient, SavedObjectsServiceStart, HttpServiceSetup, Logger, + KibanaRequest, } from '@kbn/core/server'; import type { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; @@ -28,6 +28,8 @@ import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { SavedObjectTaggingStart } from '@kbn/saved-objects-tagging-plugin/server'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; + import type { FleetConfigType } from '../../common/types'; import type { ExperimentalFeatures } from '../../common/experimental_features'; import type { @@ -161,7 +163,7 @@ class AppContextService { public getInternalUserSOClient(request: KibanaRequest) { // soClient as kibana internal users, be careful on how you use it, security is not enabled return appContextService.getSavedObjects().getScopedClient(request, { - excludedWrappers: ['security'], + excludedExtensions: [SECURITY_EXTENSION_ID], }); } diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index b4e78d2f4c2ca..d14414ab4904d 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -708,35 +708,37 @@ class PackagePolicyClientImpl implements PackagePolicyClient { } }); - const { statuses } = await soClient.bulkDelete( - idsToDelete.map((id) => ({ id, type: SAVED_OBJECT_TYPE })) - ); + if (idsToDelete.length > 0) { + const { statuses } = await soClient.bulkDelete( + idsToDelete.map((id) => ({ id, type: SAVED_OBJECT_TYPE })) + ); - statuses.forEach(({ id, success, error }) => { - const packagePolicy = packagePolicies.find((p) => p.id === id); - if (success && packagePolicy) { - result.push({ - id, - name: packagePolicy.name, - success: true, - package: { - name: packagePolicy.package?.name || '', - title: packagePolicy.package?.title || '', - version: packagePolicy.package?.version || '', - }, - policy_id: packagePolicy.policy_id, - }); - } else if (!success && error) { - result.push({ - id, - success: false, - statusCode: error.statusCode, - body: { - message: error.message, - }, - }); - } - }); + statuses.forEach(({ id, success, error }) => { + const packagePolicy = packagePolicies.find((p) => p.id === id); + if (success && packagePolicy) { + result.push({ + id, + name: packagePolicy.name, + success: true, + package: { + name: packagePolicy.package?.name || '', + title: packagePolicy.package?.title || '', + version: packagePolicy.package?.version || '', + }, + policy_id: packagePolicy.policy_id, + }); + } else if (!success && error) { + result.push({ + id, + success: false, + statusCode: error.statusCode, + body: { + message: error.message, + }, + }); + } + }); + } if (!options?.skipUnassignFromAgentPolicies) { const uniquePolicyIdsR = [ diff --git a/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts index 7743b109dbd61..949e460030cf6 100644 --- a/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts +++ b/x-pack/plugins/saved_objects_tagging/server/services/tags/tags_client.test.ts @@ -218,7 +218,8 @@ describe('TagsClient', () => { expect.objectContaining({ type: 'tag', perPage: 1000, - }) + }), + undefined // internalOptions ); }); diff --git a/x-pack/plugins/security/server/audit/audit_events.test.ts b/x-pack/plugins/security/server/audit/audit_events.test.ts index 1f9ab461e0b00..fb246569fcf81 100644 --- a/x-pack/plugins/security/server/audit/audit_events.test.ts +++ b/x-pack/plugins/security/server/audit/audit_events.test.ts @@ -7,13 +7,13 @@ import { URL } from 'url'; +import { AuditAction } from '@kbn/core-saved-objects-server'; import { httpServerMock } from '@kbn/core/server/mocks'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { AuthenticationResult } from '../authentication'; import { httpRequestEvent, - SavedObjectAction, savedObjectEvent, sessionCleanupEvent, SpaceAuditAction, @@ -26,7 +26,7 @@ describe('#savedObjectEvent', () => { test('creates event with `unknown` outcome', () => { expect( savedObjectEvent({ - action: SavedObjectAction.CREATE, + action: AuditAction.CREATE, outcome: 'unknown', savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, }) @@ -59,7 +59,7 @@ describe('#savedObjectEvent', () => { test('creates event with `success` outcome', () => { expect( savedObjectEvent({ - action: SavedObjectAction.CREATE, + action: AuditAction.CREATE, savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, }) ).toMatchInlineSnapshot(` @@ -91,7 +91,7 @@ describe('#savedObjectEvent', () => { test('creates event with `failure` outcome', () => { expect( savedObjectEvent({ - action: SavedObjectAction.CREATE, + action: AuditAction.CREATE, savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, error: new Error('ERROR_MESSAGE'), }) @@ -127,19 +127,19 @@ describe('#savedObjectEvent', () => { test('does create event for read access of saved objects', () => { expect( savedObjectEvent({ - action: SavedObjectAction.GET, + action: AuditAction.GET, savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, }) ).not.toBeUndefined(); expect( savedObjectEvent({ - action: SavedObjectAction.RESOLVE, + action: AuditAction.RESOLVE, savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, }) ).not.toBeUndefined(); expect( savedObjectEvent({ - action: SavedObjectAction.FIND, + action: AuditAction.FIND, savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, }) ).not.toBeUndefined(); @@ -148,37 +148,37 @@ describe('#savedObjectEvent', () => { test('does not create event for read access of config or telemetry objects', () => { expect( savedObjectEvent({ - action: SavedObjectAction.GET, + action: AuditAction.GET, savedObject: { type: 'config', id: 'SAVED_OBJECT_ID' }, }) ).toBeUndefined(); expect( savedObjectEvent({ - action: SavedObjectAction.GET, + action: AuditAction.GET, savedObject: { type: 'telemetry', id: 'SAVED_OBJECT_ID' }, }) ).toBeUndefined(); expect( savedObjectEvent({ - action: SavedObjectAction.RESOLVE, + action: AuditAction.RESOLVE, savedObject: { type: 'config', id: 'SAVED_OBJECT_ID' }, }) ).toBeUndefined(); expect( savedObjectEvent({ - action: SavedObjectAction.RESOLVE, + action: AuditAction.RESOLVE, savedObject: { type: 'telemetry', id: 'SAVED_OBJECT_ID' }, }) ).toBeUndefined(); expect( savedObjectEvent({ - action: SavedObjectAction.FIND, + action: AuditAction.FIND, savedObject: { type: 'config', id: 'SAVED_OBJECT_ID' }, }) ).toBeUndefined(); expect( savedObjectEvent({ - action: SavedObjectAction.FIND, + action: AuditAction.FIND, savedObject: { type: 'telemetry', id: 'SAVED_OBJECT_ID' }, }) ).toBeUndefined(); @@ -187,13 +187,13 @@ describe('#savedObjectEvent', () => { test('does create event for write access of config or telemetry objects', () => { expect( savedObjectEvent({ - action: SavedObjectAction.UPDATE, + action: AuditAction.UPDATE, savedObject: { type: 'config', id: 'SAVED_OBJECT_ID' }, }) ).not.toBeUndefined(); expect( savedObjectEvent({ - action: SavedObjectAction.UPDATE, + action: AuditAction.UPDATE, savedObject: { type: 'telemetry', id: 'SAVED_OBJECT_ID' }, }) ).not.toBeUndefined(); @@ -202,7 +202,7 @@ describe('#savedObjectEvent', () => { test('creates event with `success` outcome for `REMOVE_REFERENCES` action', () => { expect( savedObjectEvent({ - action: SavedObjectAction.REMOVE_REFERENCES, + action: AuditAction.REMOVE_REFERENCES, savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, }) ).toMatchInlineSnapshot(` diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index deb4b356c9f95..5e38d5d53f224 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -5,6 +5,10 @@ * 2.0. */ +import type { + AuditAction, + AddAuditEventParams as SavedObjectEventParams, +} from '@kbn/core-saved-objects-server'; import type { EcsEventOutcome, EcsEventType, KibanaRequest, LogMeta } from '@kbn/core/server'; import type { AuthenticationProvider } from '../../common/model'; @@ -224,23 +228,9 @@ export function accessAgreementAcknowledgedEvent({ }; } -export enum SavedObjectAction { - CREATE = 'saved_object_create', - GET = 'saved_object_get', - RESOLVE = 'saved_object_resolve', - UPDATE = 'saved_object_update', - DELETE = 'saved_object_delete', - FIND = 'saved_object_find', - REMOVE_REFERENCES = 'saved_object_remove_references', - OPEN_POINT_IN_TIME = 'saved_object_open_point_in_time', - CLOSE_POINT_IN_TIME = 'saved_object_close_point_in_time', - COLLECT_MULTINAMESPACE_REFERENCES = 'saved_object_collect_multinamespace_references', // this is separate from 'saved_object_get' because the user is only accessing an object's metadata - UPDATE_OBJECTS_SPACES = 'saved_object_update_objects_spaces', // this is separate from 'saved_object_update' because the user is only updating an object's metadata -} - type VerbsTuple = [string, string, string]; -const savedObjectAuditVerbs: Record = { +const savedObjectAuditVerbs: Record = { saved_object_create: ['create', 'creating', 'created'], saved_object_get: ['access', 'accessing', 'accessed'], saved_object_resolve: ['resolve', 'resolving', 'resolved'], @@ -274,7 +264,7 @@ const savedObjectAuditVerbs: Record = { ], }; -const savedObjectAuditTypes: Record = { +const savedObjectAuditTypes: Record = { saved_object_create: 'creation', saved_object_get: 'access', saved_object_resolve: 'access', @@ -288,15 +278,6 @@ const savedObjectAuditTypes: Record = { saved_object_update_objects_spaces: 'change', }; -export interface SavedObjectEventParams { - action: SavedObjectAction; - outcome?: EcsEventOutcome; - savedObject?: NonNullable['saved_object']; - addToSpaces?: readonly string[]; - deleteFromSpaces?: readonly string[]; - error?: Error; -} - export function savedObjectEvent({ action, savedObject, diff --git a/x-pack/plugins/security/server/audit/index.ts b/x-pack/plugins/security/server/audit/index.ts index 0bd8492b79670..c3cb5f890ce0c 100644 --- a/x-pack/plugins/security/server/audit/index.ts +++ b/x-pack/plugins/security/server/audit/index.ts @@ -16,6 +16,5 @@ export { httpRequestEvent, savedObjectEvent, spaceAuditEvent, - SavedObjectAction, SpaceAuditAction, } from './audit_events'; diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 9e5724b6a393b..573af8dbb7b67 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -311,7 +311,6 @@ export class SecurityPlugin audit: this.auditSetup, authz: this.authorizationSetup, savedObjects: core.savedObjects, - getSpacesService: () => spaces?.spacesService, }); this.registerDeprecations(core, license); diff --git a/x-pack/plugins/security/server/saved_objects/authorization_utils.test.ts b/x-pack/plugins/security/server/saved_objects/authorization_utils.test.ts new file mode 100644 index 0000000000000..cb87c9e220bb2 --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/authorization_utils.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AuthorizationTypeMap } from '@kbn/core-saved-objects-server'; + +import { getEnsureAuthorizedActionResult, isAuthorizedInAllSpaces } from './authorization_utils'; + +describe('getEnsureAuthorizedActionResult', () => { + const typeMap: AuthorizationTypeMap<'action'> = new Map([ + ['type', { action: { authorizedSpaces: ['space-id'] } }], + ]); + + test('returns the appropriate result if it is in the typeMap', () => { + const result = getEnsureAuthorizedActionResult('type', 'action', typeMap); + expect(result).toEqual({ authorizedSpaces: ['space-id'] }); + }); + + test('returns an unauthorized result if it is not in the typeMap', () => { + const result = getEnsureAuthorizedActionResult('other-type', 'action', typeMap); + expect(result).toEqual({ authorizedSpaces: [] }); + }); +}); + +describe('isAuthorizedInAllSpaces', () => { + const typeMap: AuthorizationTypeMap<'action'> = new Map([ + ['type-1', { action: { authorizedSpaces: [], isGloballyAuthorized: true } }], + ['type-2', { action: { authorizedSpaces: ['space-1', 'space-2'] } }], + ['type-3', { action: { authorizedSpaces: [] } }], + // type-4 is not present in the results + ]); + + test('returns true if the user is authorized for the type in the given spaces', () => { + const type1Result = isAuthorizedInAllSpaces( + 'type-1', + 'action', + ['space-1', 'space-2', 'space-3'], + typeMap + ); + expect(type1Result).toBe(true); + + // check for all authorized spaces + const type2AllSpacesResult = isAuthorizedInAllSpaces( + 'type-2', + 'action', + ['space-1', 'space-2'], + typeMap + ); + expect(type2AllSpacesResult).toBe(true); + + // check for subset of authorized spaces + const type2Space2Result = isAuthorizedInAllSpaces('type-2', 'action', ['space-2'], typeMap); + expect(type2Space2Result).toBe(true); + }); + + test('returns false if the user is not authorized for the type in the given spaces', () => { + const type2Result = isAuthorizedInAllSpaces( + 'type-2', + 'action', + [ + 'space-1', + 'space-2', + 'space-3', // the user is not authorized for this type and action in space-3 + ], + typeMap + ); + expect(type2Result).toBe(false); + + const type3Result = isAuthorizedInAllSpaces( + 'type-3', + 'action', + ['space-1'], // the user is not authorized for this type and action in any space + typeMap + ); + expect(type3Result).toBe(false); + + const type4Result = isAuthorizedInAllSpaces( + 'type-4', + 'action', + ['space-1'], // the user is not authorized for this type and action in any space + typeMap + ); + expect(type4Result).toBe(false); + }); +}); diff --git a/x-pack/plugins/security/server/saved_objects/authorization_utils.ts b/x-pack/plugins/security/server/saved_objects/authorization_utils.ts new file mode 100644 index 0000000000000..21b8055d2d428 --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/authorization_utils.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AuthorizationTypeEntry, AuthorizationTypeMap } from '@kbn/core-saved-objects-server'; + +/** + * Helper function that, given an `CheckAuthorizationResult`, checks to see what spaces the user is authorized to perform a given action for + * the given object type. + * + * Only exported for unit testing purposes. + * + * @param {string} objectType the object type to check. + * @param {T} action the action to check. + * @param {AuthorizationTypeMap} typeMap the typeMap from an CheckAuthorizationResult. + */ +export function getEnsureAuthorizedActionResult( + objectType: string, + action: A, + typeMap: AuthorizationTypeMap +): AuthorizationTypeEntry { + const record = typeMap.get(objectType) ?? ({} as Record); + return record[action] ?? { authorizedSpaces: [] }; +} + +/** + * Helper function that, given an `CheckAuthorizationResult`, ensures that the user is authorized to perform a given action for the given + * object type in the given spaces. + * + * @param objectType The object type to check. + * @param action The action to check. + * @param spaces The spaces to check. + * @param typeMap The typeMap from a CheckAuthorizationResult. + */ +export function isAuthorizedInAllSpaces( + objectType: string, + action: T, + spaces: string[], + typeMap: AuthorizationTypeMap +) { + const actionResult = getEnsureAuthorizedActionResult(objectType, action, typeMap); + const { authorizedSpaces, isGloballyAuthorized } = actionResult; + const authorizedSpacesSet = new Set(authorizedSpaces); + return isGloballyAuthorized || spaces.every((space) => authorizedSpacesSet.has(space)); +} diff --git a/x-pack/plugins/security/server/saved_objects/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts index 71a945e951771..67e6d2294cc5c 100644 --- a/x-pack/plugins/security/server/saved_objects/index.ts +++ b/x-pack/plugins/security/server/saved_objects/index.ts @@ -10,8 +10,7 @@ import { SavedObjectsClient } from '@kbn/core/server'; import type { AuditServiceSetup } from '../audit'; import type { AuthorizationServiceSetupInternal } from '../authorization'; -import type { SpacesService } from '../plugin'; -import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; +import { SavedObjectsSecurityExtension } from './saved_objects_security_extension'; interface SetupSavedObjectsParams { audit: AuditServiceSetup; @@ -20,50 +19,33 @@ interface SetupSavedObjectsParams { 'mode' | 'actions' | 'checkSavedObjectsPrivilegesWithRequest' >; savedObjects: CoreSetup['savedObjects']; - getSpacesService(): SpacesService | undefined; } -export type { - EnsureAuthorizedDependencies, - EnsureAuthorizedOptions, - EnsureAuthorizedResult, - EnsureAuthorizedActionResult, -} from './ensure_authorized'; - -export { - ensureAuthorized, - getEnsureAuthorizedActionResult, - isAuthorizedForObjectInAllSpaces, -} from './ensure_authorized'; - -export function setupSavedObjects({ - audit, - authz, - savedObjects, - getSpacesService, -}: SetupSavedObjectsParams) { +export function setupSavedObjects({ audit, authz, savedObjects }: SetupSavedObjectsParams) { savedObjects.setClientFactoryProvider( + // This is not used by Kibana itself, but it can be leveraged for Kibana to use a third-party authentication header if there is a custom + // authentication layer sitting between Kibana and Elasticsearch, and if Elasticsearch security is disabled. It's unclear if it's even + // possible for that to function anymore, perhaps we should deprecate this custom client factory provider and remove it in 9.0? (repositoryFactory) => - ({ request, includedHiddenTypes }) => { + ({ request, includedHiddenTypes, extensions }) => { return new SavedObjectsClient( authz.mode.useRbacForRequest(request) - ? repositoryFactory.createInternalRepository(includedHiddenTypes) - : repositoryFactory.createScopedRepository(request, includedHiddenTypes) + ? repositoryFactory.createInternalRepository(includedHiddenTypes, extensions) + : repositoryFactory.createScopedRepository(request, includedHiddenTypes, extensions) ); } ); - savedObjects.addClientWrapper(Number.MAX_SAFE_INTEGER - 1, 'security', ({ client, request }) => { + savedObjects.setSecurityExtension(({ request }) => { return authz.mode.useRbacForRequest(request) - ? new SecureSavedObjectsClientWrapper({ + ? new SavedObjectsSecurityExtension({ actions: authz.actions, auditLogger: audit.asScoped(request), - baseClient: client, - checkSavedObjectsPrivilegesAsCurrentUser: - authz.checkSavedObjectsPrivilegesWithRequest(request), + checkPrivileges: authz.checkSavedObjectsPrivilegesWithRequest(request), errors: SavedObjectsClient.errors, - getSpacesService, }) - : client; + : undefined; }); } + +export { SavedObjectsSecurityExtension }; diff --git a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts new file mode 100644 index 0000000000000..3c6ff01aea920 --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.test.ts @@ -0,0 +1,522 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AuditAction } from '@kbn/core-saved-objects-server'; +import type { EcsEventOutcome, SavedObjectsClient } from '@kbn/core/server'; + +import { auditLoggerMock } from '../audit/mocks'; +import type { CheckSavedObjectsPrivileges } from '../authorization'; +import { Actions } from '../authorization'; +import type { CheckPrivilegesResponse } from '../authorization/types'; +import { SavedObjectsSecurityExtension } from './saved_objects_security_extension'; + +function setup() { + const actions = new Actions('some-version'); + jest + .spyOn(actions.savedObject, 'get') + .mockImplementation((type: string, action: string) => `mock-saved_object:${type}/${action}`); + const auditLogger = auditLoggerMock.create(); + const errors = { + decorateForbiddenError: jest.fn().mockImplementation((err) => err), + decorateGeneralError: jest.fn().mockImplementation((err) => err), + } as unknown as jest.Mocked; + const checkPrivileges: jest.MockedFunction = jest.fn(); + const securityExtension = new SavedObjectsSecurityExtension({ + actions, + auditLogger, + errors, + checkPrivileges, + }); + return { actions, auditLogger, errors, checkPrivileges, securityExtension }; +} + +describe('#checkAuthorization', () => { + // These arguments are used for all unit tests below + const types = new Set(['a', 'b', 'c']); + const spaces = new Set(['x', 'y']); + const actions = new Set(['foo', 'bar']); + + const fullyAuthorizedCheckPrivilegesResponse = { + hasAllRequested: true, + privileges: { + kibana: [ + { privilege: 'mock-saved_object:a/foo', authorized: true }, + { privilege: 'mock-saved_object:a/bar', authorized: true }, + { privilege: 'login:', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true }, + ], + }, + } as CheckPrivilegesResponse; + + test('calls checkPrivileges with expected privilege actions and namespaces', async () => { + const { securityExtension, checkPrivileges } = setup(); + checkPrivileges.mockResolvedValue(fullyAuthorizedCheckPrivilegesResponse); // Return any well-formed response to avoid an unhandled error + + await securityExtension.checkAuthorization({ types, spaces, actions }); + expect(checkPrivileges).toHaveBeenCalledWith( + [ + 'mock-saved_object:a/foo', + 'mock-saved_object:a/bar', + 'mock-saved_object:b/foo', + 'mock-saved_object:b/bar', + 'mock-saved_object:c/foo', + 'mock-saved_object:c/bar', + 'login:', + ], + [...spaces] + ); + }); + + test('throws an error when `types` is empty', async () => { + const { securityExtension, checkPrivileges } = setup(); + + await expect( + securityExtension.checkAuthorization({ types: new Set(), spaces, actions }) + ).rejects.toThrowError('No types specified for authorization check'); + expect(checkPrivileges).not.toHaveBeenCalled(); + }); + + test('throws an error when `spaces` is empty', async () => { + const { securityExtension, checkPrivileges } = setup(); + + await expect( + securityExtension.checkAuthorization({ types, spaces: new Set(), actions }) + ).rejects.toThrowError('No spaces specified for authorization check'); + expect(checkPrivileges).not.toHaveBeenCalled(); + }); + + test('throws an error when `actions` is empty', async () => { + const { securityExtension, checkPrivileges } = setup(); + + await expect( + securityExtension.checkAuthorization({ types, spaces, actions: new Set([]) }) + ).rejects.toThrowError('No actions specified for authorization check'); + expect(checkPrivileges).not.toHaveBeenCalled(); + }); + + test('throws an error when privilege check fails', async () => { + const { securityExtension, checkPrivileges } = setup(); + checkPrivileges.mockRejectedValue(new Error('Oh no!')); + + await expect( + securityExtension.checkAuthorization({ types, spaces, actions }) + ).rejects.toThrowError('Oh no!'); + }); + + test('fully authorized', async () => { + const { securityExtension, checkPrivileges } = setup(); + checkPrivileges.mockResolvedValue(fullyAuthorizedCheckPrivilegesResponse); + + const result = await securityExtension.checkAuthorization({ types, spaces, actions }); + expect(result).toEqual({ + status: 'fully_authorized', + typeMap: new Map() + .set('a', { + foo: { isGloballyAuthorized: true, authorizedSpaces: [] }, + bar: { isGloballyAuthorized: true, authorizedSpaces: [] }, + // Technically, 'login:' is not a saved object action, it is a Kibana privilege -- however, we include it in the `typeMap` results + // for ease of use with the `redactNamespaces` function. The user is never actually authorized to "login" for a given object type, + // they are authorized to log in on a per-space basis, and this is applied to each object type in the typeMap result accordingly. + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('b', { + foo: { authorizedSpaces: ['x', 'y'] }, + bar: { authorizedSpaces: ['x', 'y'] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('c', { + foo: { authorizedSpaces: ['x', 'y'] }, + bar: { authorizedSpaces: ['x', 'y'] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }), + }); + }); + + test('partially authorized', async () => { + const { securityExtension, checkPrivileges } = setup(); + checkPrivileges.mockResolvedValue({ + hasAllRequested: false, + privileges: { + kibana: [ + // For type 'a', the user is authorized to use 'foo' action but not 'bar' action (all spaces) + // For type 'b', the user is authorized to use 'foo' action but not 'bar' action (both spaces) + // For type 'c', the user is authorized to use both actions in space 'x' but not space 'y' + { privilege: 'mock-saved_object:a/foo', authorized: true }, + { privilege: 'mock-saved_object:a/bar', authorized: false }, + { privilege: 'mock-saved_object:a/bar', authorized: true }, // fail-secure check + { resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: false }, + { resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: true }, + { privilege: 'mock-saved_object:c/foo', authorized: false }, // inverse fail-secure check + { resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: true }, + { resource: 'x', privilege: 'login:', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: false }, + { privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check + { resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check + { resource: 'y', privilege: 'login:', authorized: true }, + // The fail-secure checks are a contrived scenario, as we *shouldn't* get both an unauthorized and authorized result for a given resource... + // However, in case we do, we should fail-secure (authorized + unauthorized = unauthorized) + ], + }, + } as CheckPrivilegesResponse); + + const result = await securityExtension.checkAuthorization({ types, spaces, actions }); + expect(result).toEqual({ + status: 'partially_authorized', + typeMap: new Map() + .set('a', { + foo: { isGloballyAuthorized: true, authorizedSpaces: [] }, + ['login:']: { authorizedSpaces: ['x', 'y'] }, + }) + .set('b', { + foo: { authorizedSpaces: ['x', 'y'] }, + ['login:']: { authorizedSpaces: ['x', 'y'] }, + }) + .set('c', { + foo: { authorizedSpaces: ['x'] }, + bar: { authorizedSpaces: ['x'] }, + ['login:']: { authorizedSpaces: ['x', 'y'] }, + }), + }); + }); + + test('unauthorized', async () => { + const { securityExtension, checkPrivileges } = setup(); + checkPrivileges.mockResolvedValue({ + hasAllRequested: false, + privileges: { + kibana: [ + { privilege: 'mock-saved_object:a/foo', authorized: false }, + { privilege: 'mock-saved_object:a/bar', authorized: false }, + { privilege: 'mock-saved_object:a/bar', authorized: true }, // fail-secure check + { resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: false }, + { resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: false }, + { resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: false }, + { resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: false }, + { resource: 'x', privilege: 'login:', authorized: false }, + { resource: 'x', privilege: 'login:', authorized: true }, // fail-secure check + { resource: 'y', privilege: 'mock-saved_object:a/foo', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:a/bar', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: false }, + { privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check + { resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check + { resource: 'y', privilege: 'login:', authorized: true }, // should *not* result in a 'partially_authorized' status + // The fail-secure checks are a contrived scenario, as we *shouldn't* get both an unauthorized and authorized result for a given resource... + // However, in case we do, we should fail-secure (authorized + unauthorized = unauthorized) + ], + }, + } as CheckPrivilegesResponse); + + const result = await securityExtension.checkAuthorization({ types, spaces, actions }); + expect(result).toEqual({ + // The user is authorized to log into space Y, but they are not authorized to take any actions on any of the requested object types. + // Therefore, the status is 'unauthorized'. + status: 'unauthorized', + typeMap: new Map() + .set('a', { ['login:']: { authorizedSpaces: ['y'] } }) + .set('b', { ['login:']: { authorizedSpaces: ['y'] } }) + .set('c', { ['login:']: { authorizedSpaces: ['y'] } }), + }); + }); + + test('conflicting privilege failsafe', async () => { + const conflictingPrivilegesResponse = { + hasAllRequested: true, + privileges: { + kibana: [ + // redundant conflicting privileges for space X, type B, action Foo + { resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true }, + ], + }, + } as CheckPrivilegesResponse; + + const { securityExtension, checkPrivileges } = setup(); + checkPrivileges.mockResolvedValue(conflictingPrivilegesResponse); + + const result = await securityExtension.checkAuthorization({ types, spaces, actions }); + expect(result).toEqual({ + status: 'fully_authorized', + typeMap: new Map().set('b', { + foo: { authorizedSpaces: ['y'] }, // should NOT be authorized for conflicted privilege + }), + }); + }); +}); + +describe('#enforceAuthorization', () => { + test('fully authorized', () => { + const { securityExtension } = setup(); + + const authorizationResult = { + status: 'fully_authorized', + typeMap: new Map() + .set('a', { + foo: { isGloballyAuthorized: true, authorizedSpaces: [] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('b', { + foo: { authorizedSpaces: ['x', 'y'] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('c', { + foo: { authorizedSpaces: ['y', 'z'] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }), + }; + + const spacesToEnforce = new Set(['x', 'y', 'z']); + + expect(() => + securityExtension.enforceAuthorization({ + typesAndSpaces: new Map([ + ['a', spacesToEnforce], + ['b', new Set(['x', 'y'])], + ['c', new Set(['y', 'z'])], + ]), + action: 'foo', + typeMap: authorizationResult.typeMap, + }) + ).not.toThrowError(); + }); + + test('partially authorized', () => { + const { securityExtension } = setup(); + + const authorizationResult = { + status: 'partially_authorized', + typeMap: new Map() + .set('a', { + foo: { isGloballyAuthorized: true, authorizedSpaces: [] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('b', { + foo: { authorizedSpaces: ['x'] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('c', { + foo: { authorizedSpaces: ['z'] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }), + }; + + const spacesToEnforce = new Set(['x', 'y', 'z']); + + expect(() => + securityExtension.enforceAuthorization({ + typesAndSpaces: new Map([ + ['a', spacesToEnforce], + ['b', new Set(['x', 'y'])], + ['c', new Set(['y', 'z'])], + ]), + action: 'foo', + typeMap: authorizationResult.typeMap, + }) + ).toThrowError('Unable to foo b,c'); + }); + + test('unauthorized', () => { + const { securityExtension } = setup(); + + const authorizationResult = { + status: 'unauthorized', + typeMap: new Map() + .set('a', { + foo: { authorizedSpaces: ['x'] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('b', { + foo: { authorizedSpaces: ['y'] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('c', { + foo: { authorizedSpaces: ['z'] }, + ['login:']: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }), + }; + + expect(() => + securityExtension.enforceAuthorization({ + typesAndSpaces: new Map([ + ['a', new Set(['y', 'z'])], + ['b', new Set(['x', 'z'])], + ['c', new Set(['x', 'y'])], + ]), + action: 'foo', + typeMap: authorizationResult.typeMap, + }) + ).toThrowError('Unable to foo a,b,c'); + }); +}); + +describe('#addAuditEvent', () => { + test(`adds an unknown audit event`, async () => { + const { auditLogger, securityExtension } = setup(); + const action = AuditAction.UPDATE_OBJECTS_SPACES; + const outcome: EcsEventOutcome = 'unknown'; + const savedObject = { type: 'dashboard', id: '3' }; + const spaces = ['space-id']; + + const auditParams = { + action, + outcome, + savedObject, + deleteFromSpaces: spaces, + }; + + securityExtension.addAuditEvent(auditParams); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action, + outcome, + }), + kibana: savedObject + ? expect.objectContaining({ + saved_object: savedObject, + delete_from_spaces: spaces, + }) + : expect.anything(), + message: `User is updating spaces of ${savedObject.type} [id=${savedObject.id}]`, + }) + ); + }); + + test(`adds a success audit event`, async () => { + const { auditLogger, securityExtension } = setup(); + const action = AuditAction.UPDATE_OBJECTS_SPACES; + const outcome: EcsEventOutcome = 'success'; + const savedObject = { type: 'dashboard', id: '3' }; + const spaces = ['space-id']; + + const auditParams = { + action, + outcome, + savedObject, + addToSpaces: spaces, + }; + + securityExtension.addAuditEvent(auditParams); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action, + outcome, + }), + kibana: savedObject + ? expect.objectContaining({ + saved_object: savedObject, + add_to_spaces: spaces, + }) + : expect.anything(), + message: `User has updated spaces of ${savedObject.type} [id=${savedObject.id}]`, + }) + ); + }); + + test(`adds a failure audit event`, async () => { + const { auditLogger, securityExtension } = setup(); + const action = AuditAction.DELETE; + const outcome: EcsEventOutcome = 'failure'; + const savedObject = { type: 'dashboard', id: '3' }; + const error: Error = { + name: 'test_error', + message: 'this is just a test', + }; + + const auditParams = { + action, + outcome, + savedObject, + error, + }; + + securityExtension.addAuditEvent(auditParams); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + error: { code: error.name, message: error.message }, + event: expect.objectContaining({ + action, + outcome, + }), + kibana: savedObject + ? expect.objectContaining({ + saved_object: savedObject, + }) + : expect.anything(), + message: `Failed attempt to delete ${savedObject.type} [id=${savedObject.id}]`, + }) + ); + }); +}); + +describe('#redactNamespaces', () => { + test(`filters namespaces that the user doesn't have access to`, () => { + const { securityExtension } = setup(); + + const typeMap = new Map().set('so-type', { + // redact is only concerned with 'login' attribute, not specific action + ['login:']: { authorizedSpaces: ['authorized-space'] }, + }); + + const so = { + id: 'some-id', + type: 'so-type', + namespaces: ['authorized-space', 'unauthorized-space'], + attributes: { + test: 'attr', + }, + score: 1, + references: [], + }; + + const result = securityExtension.redactNamespaces({ typeMap, savedObject: so }); + expect(result).toEqual(expect.objectContaining({ namespaces: ['authorized-space', '?'] })); + }); + + test(`does not redact on isGloballyAuthorized`, () => { + const { securityExtension } = setup(); + + const typeMap = new Map().set('so-type', { + // redact is only concerned with 'login' attribute, not specific action + ['login:']: { isGloballyAuthorized: true }, + }); + + const so = { + id: 'some-id', + type: 'so-type', + namespaces: ['space-a', 'space-b', 'space-c'], + attributes: { + test: 'attr', + }, + score: 1, + references: [], + }; + + const result = securityExtension.redactNamespaces({ typeMap, savedObject: so }); + expect(result).toEqual( + expect.objectContaining({ namespaces: ['space-a', 'space-b', 'space-c'] }) + ); + }); +}); diff --git a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts new file mode 100644 index 0000000000000..07c720c0aa59b --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsClient } from '@kbn/core-saved-objects-api-server-internal'; +import type { SavedObject } from '@kbn/core-saved-objects-common'; +import type { + AddAuditEventParams, + AuthorizationTypeEntry, + AuthorizationTypeMap, + CheckAuthorizationParams, + CheckAuthorizationResult, + EnforceAuthorizationParams, + ISavedObjectsSecurityExtension, + RedactNamespacesParams, +} from '@kbn/core-saved-objects-server'; + +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; +import type { AuditLogger } from '../audit'; +import { savedObjectEvent } from '../audit'; +import type { Actions, CheckSavedObjectsPrivileges } from '../authorization'; +import type { CheckPrivilegesResponse } from '../authorization/types'; +import { isAuthorizedInAllSpaces } from './authorization_utils'; + +interface Params { + actions: Actions; + auditLogger: AuditLogger; + errors: SavedObjectsClient['errors']; + checkPrivileges: CheckSavedObjectsPrivileges; +} + +export class SavedObjectsSecurityExtension implements ISavedObjectsSecurityExtension { + private readonly actions: Actions; + private readonly auditLogger: AuditLogger; + private readonly errors: SavedObjectsClient['errors']; + private readonly checkPrivilegesFunc: CheckSavedObjectsPrivileges; + + constructor({ actions, auditLogger, errors, checkPrivileges }: Params) { + this.actions = actions; + this.auditLogger = auditLogger; + this.errors = errors; + this.checkPrivilegesFunc = checkPrivileges; + } + + async checkAuthorization( + params: CheckAuthorizationParams + ): Promise> { + const { types, spaces, actions, options = { allowGlobalResource: false } } = params; + const { allowGlobalResource } = options; + if (types.size === 0) { + throw new Error('No types specified for authorization check'); + } + if (spaces.size === 0) { + throw new Error('No spaces specified for authorization check'); + } + if (actions.size === 0) { + throw new Error('No actions specified for authorization check'); + } + const typesArray = [...types]; + const actionsArray = [...actions]; + const privilegeActionsMap = new Map( + typesArray.flatMap((type) => + actionsArray.map((action) => [this.actions.savedObject.get(type, action), { type, action }]) + ) + ); + const privilegeActions = [...privilegeActionsMap.keys(), this.actions.login]; // Always check login action, we will need it later for redacting namespaces + const { hasAllRequested, privileges } = await this.checkPrivileges( + privilegeActions, + getAuthorizableSpaces(spaces, allowGlobalResource) + ); + + const missingPrivileges = getMissingPrivileges(privileges); + const typeMap = privileges.kibana.reduce>( + (acc, { resource, privilege }) => { + const missingPrivilegesAtResource = + (resource && missingPrivileges.get(resource)?.has(privilege)) || + (!resource && missingPrivileges.get(undefined)?.has(privilege)); + + if (missingPrivilegesAtResource) { + return acc; + } + + let objTypes: string[]; + let action: A; + if (privilege === this.actions.login) { + // Technically, 'login:' is not a saved object action, it is a Kibana privilege -- however, we include it in the `typeMap` results + // for ease of use with the `redactNamespaces` function. The user is never actually authorized to "login" for a given object type, + // they are authorized to log in on a per-space basis, and this is applied to each object type in the typeMap result accordingly. + objTypes = typesArray; + action = this.actions.login as A; + } else { + const entry = privilegeActionsMap.get(privilege)!; // always defined + objTypes = [entry.type]; + action = entry.action; + } + + for (const type of objTypes) { + const actionAuthorizations = acc.get(type) ?? ({} as Record); + const authorization: AuthorizationTypeEntry = actionAuthorizations[action] ?? { + authorizedSpaces: [], + }; + + if (resource === undefined) { + acc.set(type, { + ...actionAuthorizations, + [action]: { ...authorization, isGloballyAuthorized: true }, + }); + } else { + acc.set(type, { + ...actionAuthorizations, + [action]: { + ...authorization, + authorizedSpaces: authorization.authorizedSpaces.concat(resource), + }, + }); + } + } + return acc; + }, + new Map() + ); + + if (hasAllRequested) { + return { typeMap, status: 'fully_authorized' }; + } else if (typeMap.size > 0) { + for (const entry of typeMap.values()) { + const typeActions = Object.keys(entry); + if (actionsArray.some((a) => typeActions.includes(a))) { + // Only return 'partially_authorized' if the user is actually authorized for one of the actions they requested + // (e.g., not just the 'login:' action) + return { typeMap, status: 'partially_authorized' }; + } + } + } + return { typeMap, status: 'unauthorized' }; + } + + enforceAuthorization(params: EnforceAuthorizationParams): void { + const { typesAndSpaces, action, typeMap, auditCallback } = params; + const unauthorizedTypes = new Set(); + for (const [type, spaces] of typesAndSpaces) { + const spacesArray = [...spaces]; + if (!isAuthorizedInAllSpaces(type, action, spacesArray, typeMap)) { + unauthorizedTypes.add(type); + } + } + + if (unauthorizedTypes.size > 0) { + const targetTypes = [...unauthorizedTypes].sort().join(','); + const msg = `Unable to ${action} ${targetTypes}`; + const error = this.errors.decorateForbiddenError(new Error(msg)); + auditCallback?.(error); + throw error; + } + auditCallback?.(); + } + + addAuditEvent(params: AddAuditEventParams): void { + if (this.auditLogger.enabled) { + const auditEvent = savedObjectEvent(params); + this.auditLogger.log(auditEvent); + } + } + + redactNamespaces(params: RedactNamespacesParams): SavedObject { + const { savedObject, typeMap } = params; + const loginAction = this.actions.login as A; // This typeMap came from the `checkAuthorization` function, which always checks privileges for the "login" action (in addition to what the consumer requested) + const actionRecord = typeMap.get(savedObject.type); + const entry: AuthorizationTypeEntry = actionRecord?.[loginAction] ?? { authorizedSpaces: [] }; // fail-secure if attribute is not defined + const { authorizedSpaces, isGloballyAuthorized } = entry; + + if (isGloballyAuthorized || !savedObject.namespaces?.length) { + return savedObject; + } + const authorizedSpacesSet = new Set(authorizedSpaces); + const redactedSpaces = savedObject.namespaces + ?.map((x) => (x === ALL_SPACES_ID || authorizedSpacesSet.has(x) ? x : UNKNOWN_SPACE)) + .sort(namespaceComparator); + return { ...savedObject, namespaces: redactedSpaces }; + } + + private async checkPrivileges( + actions: string | string[], + namespaceOrNamespaces?: string | Array + ) { + try { + return await this.checkPrivilegesFunc(actions, namespaceOrNamespaces); + } catch (error) { + throw this.errors.decorateGeneralError(error, error.body && error.body.reason); + } + } +} + +/** + * The '*' string is an identifier for All Spaces, but that is also the identifier for the Global Resource. We should not check + * authorization against it unless explicitly specified, because you can only check privileges for the Global Resource *or* individual + * resources (not both). + */ +function getAuthorizableSpaces(spaces: Set, allowGlobalResource?: boolean) { + const spacesArray = [...spaces]; + if (allowGlobalResource) return spacesArray; + return spacesArray.filter((x) => x !== ALL_SPACES_ID); +} + +function getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { + return privileges.kibana.reduce>>( + (acc, { resource, privilege, authorized }) => { + if (!authorized) { + if (resource) { + acc.set(resource, (acc.get(resource) || new Set()).add(privilege)); + } + // Fail-secure: if a user is not authorized for a specific resource, they are not authorized for the global resource too (global resource is undefined) + // The inverse is not true; if a user is not authorized for the global resource, they may still be authorized for a specific resource + acc.set(undefined, (acc.get(undefined) || new Set()).add(privilege)); + } + return acc; + }, + new Map() + ); +} + +/** + * Utility function to sort potentially redacted namespaces. + * Sorts in a case-insensitive manner, and ensures that redacted namespaces ('?') always show up at the end of the array. + */ +function namespaceComparator(a: string, b: string) { + if (a === UNKNOWN_SPACE && b !== UNKNOWN_SPACE) { + return 1; + } else if (a !== UNKNOWN_SPACE && b === UNKNOWN_SPACE) { + return -1; + } + const A = a.toUpperCase(); + const B = b.toUpperCase(); + return A > B ? 1 : A < B ? -1 : 0; +} diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.mocks.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.mocks.ts deleted file mode 100644 index 9e772f5394cc2..0000000000000 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.mocks.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ensureAuthorized } from './ensure_authorized'; - -export const mockEnsureAuthorized = jest.fn() as jest.MockedFunction; - -jest.mock('./ensure_authorized', () => { - return { - ...jest.requireActual('./ensure_authorized'), - ensureAuthorized: mockEnsureAuthorized, - }; -}); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts deleted file mode 100644 index bce6786f06775..0000000000000 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ /dev/null @@ -1,1984 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { mockEnsureAuthorized } from './secure_saved_objects_client_wrapper.test.mocks'; - -import type { - EcsEventOutcome, - SavedObject, - SavedObjectReferenceWithContext, - SavedObjectsErrorHelpers, - SavedObjectsResolveResponse, - SavedObjectsUpdateObjectsSpacesResponseObject, -} from '@kbn/core/server'; -import { savedObjectsClientMock } from '@kbn/core/server/mocks'; - -import type { AuditEvent } from '../audit'; -import { auditLoggerMock } from '../audit/mocks'; -import { Actions } from '../authorization'; -import type { SavedObjectActions } from '../authorization/actions/saved_object'; -import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; - -jest.mock('@kbn/core-saved-objects-utils-server', () => { - const { SavedObjectsUtils, ...actual } = jest.requireActual( - '@kbn/core-saved-objects-utils-server' - ); - return { - ...actual, - SavedObjectsUtils: { - ...SavedObjectsUtils, - createEmptyFindResponse: SavedObjectsUtils.createEmptyFindResponse, - generateId: () => 'mock-saved-object-id', - }, - }; -}); - -let clientOpts: ReturnType; -let client: SecureSavedObjectsClientWrapper; -const USERNAME = Symbol(); - -const createSecureSavedObjectsClientWrapperOptions = () => { - const actions = new Actions('some-version'); - jest - .spyOn(actions.savedObject, 'get') - .mockImplementation((type: string, action: string) => `mock-saved_object:${type}/${action}`); - - const forbiddenError = new Error('Mock ForbiddenError'); - const generalError = new Error('Mock GeneralError'); - - const errors = { - decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), - decorateGeneralError: jest.fn().mockReturnValue(generalError), - createBadRequestError: jest.fn().mockImplementation((message) => new Error(message)), - isNotFoundError: jest.fn().mockReturnValue(false), - } as unknown as jest.Mocked; - const getSpacesService = jest.fn().mockReturnValue({ - namespaceToSpaceId: (namespace?: string) => (namespace ? namespace : 'default'), - }); - - return { - actions, - baseClient: savedObjectsClientMock.create(), - checkSavedObjectsPrivilegesAsCurrentUser: jest.fn(), - errors, - getSpacesService, - auditLogger: auditLoggerMock.create(), - forbiddenError, - generalError, - }; -}; - -const expectGeneralError = async (fn: Function, args: Record) => { - // mock the checkPrivileges.globally rejection - const rejection = new Error('An actual error would happen here'); - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(rejection); - - await expect(fn.bind(client)(...Object.values(args))).rejects.toThrowError( - clientOpts.generalError - ); - expect(clientOpts.errors.decorateGeneralError).toHaveBeenCalledTimes(1); -}; - -/** - * Fails the first authorization check, passes any others - * Requires that function args are passed in as key/value pairs - * The argument properties must be in the correct order to be spread properly - */ -const expectForbiddenError = async (fn: Function, args: Record, action?: string) => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure - ); - - await expect(fn.bind(client)(...Object.values(args))).rejects.toThrowError( - clientOpts.forbiddenError - ); - - expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); -}; - -const expectSuccess = async (fn: Function, args: Record, action?: string) => { - return await fn.bind(client)(...Object.values(args)); -}; - -const expectPrivilegeCheck = async ( - fn: Function, - args: Record, - namespaceOrNamespaces: string | undefined | Array -) => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure - ); - - await expect(fn.bind(client)(...Object.values(args))).rejects.toThrow(); // test is simpler with error case - const getResults = ( - clientOpts.actions.savedObject.get as jest.MockedFunction - ).mock.results; - const actions = getResults.map((x) => x.value); - - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - actions, - namespaceOrNamespaces - ); -}; - -const expectObjectNamespaceFiltering = async ( - fn: Function, - args: Record, - privilegeChecks = 1 -) => { - for (let i = 0; i < privilegeChecks; i++) { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( - getMockCheckPrivilegesSuccess // privilege check for authorization - ); - } - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure // privilege check for namespace filtering - ); - - const authorizedNamespace = args.options?.namespace || 'default'; - const namespaces = ['some-other-namespace', '*', authorizedNamespace]; - const returnValue = { namespaces, foo: 'bar' }; - // we don't know which base client method will be called; mock them all - clientOpts.baseClient.create.mockReturnValue(returnValue as any); - clientOpts.baseClient.get.mockReturnValue(returnValue as any); - // 'resolve' is excluded because it has a specific test case written for it - clientOpts.baseClient.update.mockReturnValue(returnValue as any); - - const result = await fn.bind(client)(...Object.values(args)); - // we will never redact the "All Spaces" ID - expect(result).toEqual(expect.objectContaining({ namespaces: ['*', authorizedNamespace, '?'] })); - - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes( - privilegeChecks + 1 - ); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( - 'login:', - ['some-other-namespace'] - // when we check what namespaces to redact, we don't check privileges for '*', only actual space IDs - // we don't check privileges for authorizedNamespace either, as that was already checked earlier in the operation - ); -}; - -const expectAuditEvent = ( - action: string, - outcome: EcsEventOutcome, - savedObject?: Required['kibana']['saved_object'] -) => { - expect(clientOpts.auditLogger.log).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - action, - outcome, - }), - kibana: savedObject - ? expect.objectContaining({ - saved_object: { type: savedObject.type, id: savedObject.id }, - }) - : expect.anything(), - }) - ); -}; - -const expectObjectsNamespaceFiltering = async (fn: Function, args: Record) => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( - getMockCheckPrivilegesSuccess // privilege check for authorization - ); - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure // privilege check for namespace filtering - ); - - // the 'find' operation has options.namespaces, the others have options.namespace - const authorizedNamespaces = - args.options.namespaces ?? (args.options.namespace ? [args.options.namespace] : ['default']); - const returnValue = { - saved_objects: [ - { namespaces: ['*'] }, - { namespaces: authorizedNamespaces }, - { namespaces: ['some-other-namespace', ...authorizedNamespaces] }, - ], - }; - - // we don't know which base client method will be called; mock them all - clientOpts.baseClient.bulkCreate.mockReturnValue(returnValue as any); - clientOpts.baseClient.bulkGet.mockReturnValue(returnValue as any); - clientOpts.baseClient.bulkUpdate.mockReturnValue(returnValue as any); - clientOpts.baseClient.find.mockReturnValue(returnValue as any); - - const result = await fn.bind(client)(...Object.values(args)); - expect(result).toEqual({ - saved_objects: [ - { namespaces: ['*'] }, - { namespaces: authorizedNamespaces }, - { namespaces: [...authorizedNamespaces, '?'] }, - ], - }); - - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( - 'login:', - ['some-other-namespace'] - // when we check what namespaces to redact, we don't check privileges for '*', only actual space IDs - // we don't check privileges for authorizedNamespaces either, as that was already checked earlier in the operation - ); -}; - -function getMockCheckPrivilegesSuccess(actions: string | string[], namespaces?: string | string[]) { - const _namespaces = Array.isArray(namespaces) ? namespaces : [namespaces || 'default']; - const _actions = Array.isArray(actions) ? actions : [actions]; - return { - hasAllRequested: true, - username: USERNAME, - privileges: { - kibana: _namespaces - .map((resource) => - _actions.map((action) => ({ - resource, - privilege: action, - authorized: true, - })) - ) - .flat(), - }, - }; -} - -/** - * Fails the authorization check for the first privilege, and passes any others - * This check may be for an action for two different types in the same namespace - * Or, it may be for an action for the same type in two different namespaces - * Either way, the first privilege check returned is false, and any others return true - */ -function getMockCheckPrivilegesFailure(actions: string | string[], namespaces?: string | string[]) { - const _namespaces = Array.isArray(namespaces) ? namespaces : [namespaces || 'default']; - const _actions = Array.isArray(actions) ? actions : [actions]; - return { - hasAllRequested: false, - username: USERNAME, - privileges: { - kibana: _namespaces - .map((resource, idxa) => - _actions.map((action, idxb) => ({ - resource, - privilege: action, - authorized: idxa > 0 || idxb > 0, - })) - ) - .flat(), - }, - }; -} - -/** - * Before each test, create the Client with its Options - */ -beforeEach(() => { - clientOpts = createSecureSavedObjectsClientWrapperOptions(); - client = new SecureSavedObjectsClientWrapper(clientOpts); - - // succeed legacyEnsureAuthorized privilege checks by default - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesSuccess - ); - - mockEnsureAuthorized.mockReset(); -}); - -describe('#bulkCreate', () => { - const attributes = { some: 'attr' }; - const obj1 = Object.freeze({ type: 'foo', id: 'sup', attributes }); - const obj2 = Object.freeze({ type: 'bar', id: 'everyone', attributes }); - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const objects = [obj1]; - await expectGeneralError(client.bulkCreate, { objects }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectForbiddenError(client.bulkCreate, { objects, options }); - }); - - test(`returns result of baseClient.bulkCreate when authorized`, async () => { - const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; - clientOpts.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); - - const objects = [obj1, obj2]; - const options = { namespace }; - const result = await expectSuccess(client.bulkCreate, { objects, options }); - expect(result).toEqual(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectPrivilegeCheck(client.bulkCreate, { objects, options }, [namespace]); - }); - - test(`checks privileges for user, actions, namespace, and initialNamespaces`, async () => { - const objects = [ - { ...obj1, initialNamespaces: 'another-ns' }, - { ...obj2, initialNamespaces: 'yet-another-ns' }, - ]; - const options = { namespace }; - await expectPrivilegeCheck(client.bulkCreate, { objects, options }, [ - namespace, - 'another-ns', - 'yet-another-ns', - ]); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectObjectsNamespaceFiltering(client.bulkCreate, { objects, options }); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; - clientOpts.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); - const objects = [obj1, obj2]; - const options = { namespace }; - await expectSuccess(client.bulkCreate, { objects, options }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_create', 'unknown', { type: obj1.type, id: obj1.id }); - expectAuditEvent('saved_object_create', 'unknown', { type: obj2.type, id: obj2.id }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.bulkCreate([obj1, obj2], { namespace })).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_create', 'failure', { type: obj1.type, id: obj1.id }); - expectAuditEvent('saved_object_create', 'failure', { type: obj2.type, id: obj2.id }); - }); -}); - -describe('#bulkGet', () => { - const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); - const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const objects = [obj1]; - await expectGeneralError(client.bulkGet, { objects }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectForbiddenError(client.bulkGet, { objects, options }); - }); - - test(`returns result of baseClient.bulkGet when authorized`, async () => { - const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; - clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); - - const objects = [obj1, obj2]; - const options = { namespace }; - const result = await expectSuccess(client.bulkGet, { objects, options }); - expect(result).toEqual(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, namespace, and (object) namespaces`, async () => { - const objects = [ - { ...obj1, namespaces: ['another-ns'] }, - { ...obj2, namespaces: ['yet-another-ns'] }, - ]; - const options = { namespace }; - await expectPrivilegeCheck(client.bulkGet, { objects, options }, [ - namespace, - 'another-ns', - 'yet-another-ns', - ]); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectObjectsNamespaceFiltering(client.bulkGet, { objects, options }); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = { saved_objects: [obj1, obj2], foo: 'bar' }; - clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); - const objects = [obj1, obj2]; - const options = { namespace }; - await expectSuccess(client.bulkGet, { objects, options }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_get', 'success', obj1); - expectAuditEvent('saved_object_get', 'success', obj2); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.bulkGet([obj1, obj2], { namespace })).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_get', 'failure', obj1); - expectAuditEvent('saved_object_get', 'failure', obj2); - }); -}); - -describe('#bulkResolve', () => { - const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); - const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const objects = [obj1]; - await expectGeneralError(client.bulkResolve, { objects }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectForbiddenError(client.bulkResolve, { objects, options }, 'bulk_resolve'); - }); - - test(`returns result of baseClient.bulkResolve when authorized`, async () => { - const apiCallReturnValue = { resolved_objects: [] }; - clientOpts.baseClient.bulkResolve.mockResolvedValue(apiCallReturnValue); - - const objects = [obj1, obj2]; - const options = { namespace }; - const result = await expectSuccess(client.bulkResolve, { objects, options }, 'bulk_resolve'); - expect(result).toEqual(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectPrivilegeCheck(client.bulkResolve, { objects, options }, namespace); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( - getMockCheckPrivilegesSuccess // privilege check for authorization - ); - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure // privilege check for namespace filtering - ); - - clientOpts.baseClient.bulkResolve.mockResolvedValue({ - resolved_objects: [ - // omit other fields from the SavedObjectsResolveResponse such as outcome, as they are not needed for this test case - { saved_object: { namespaces: ['*'] } } as unknown as SavedObjectsResolveResponse, - { saved_object: { namespaces: [namespace] } } as unknown as SavedObjectsResolveResponse, - { - saved_object: { namespaces: ['some-other-namespace', namespace] }, - } as unknown as SavedObjectsResolveResponse, - ], - }); - - const result = await client.bulkResolve(objects, options); - expect(result).toEqual({ - resolved_objects: [ - { saved_object: { namespaces: ['*'] } }, - { saved_object: { namespaces: [namespace] } }, - { saved_object: { namespaces: [namespace, '?'] } }, - ], - }); - - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( - 'login:', - ['some-other-namespace'] - // when we check what namespaces to redact, we don't check privileges for '*', only actual space IDs - // we don't check privileges for authorizedNamespaces either, as that was already checked earlier in the operation - ); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = { - resolved_objects: [ - { saved_object: obj1 } as unknown as SavedObjectsResolveResponse, - { saved_object: obj2 } as unknown as SavedObjectsResolveResponse, - ], - }; - clientOpts.baseClient.bulkResolve.mockResolvedValue(apiCallReturnValue); - const objects = [obj1, obj2]; - const options = { namespace }; - await expectSuccess(client.bulkResolve, { objects, options }, 'bulk_resolve'); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_resolve', 'success', obj1); - expectAuditEvent('saved_object_resolve', 'success', obj2); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.bulkResolve([obj1, obj2], { namespace })).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_resolve', 'failure', obj1); - expectAuditEvent('saved_object_resolve', 'failure', obj2); - }); -}); - -describe('#bulkUpdate', () => { - const obj1 = Object.freeze({ type: 'foo', id: 'foo-id', attributes: { some: 'attr' } }); - const obj2 = Object.freeze({ type: 'bar', id: 'bar-id', attributes: { other: 'attr' } }); - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const objects = [obj1]; - await expectGeneralError(client.bulkUpdate, { objects }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectForbiddenError(client.bulkUpdate, { objects, options }); - }); - - test(`returns result of baseClient.bulkUpdate when authorized`, async () => { - const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; - clientOpts.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); - - const objects = [obj1, obj2]; - const options = { namespace }; - const result = await expectSuccess(client.bulkUpdate, { objects, options }); - expect(result).toEqual(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - const namespaces = [options.namespace]; // the bulkUpdate function always checks privileges as an array - await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespaces); - }); - - test(`checks privileges for object namespaces if present`, async () => { - const objects = [ - { ...obj1, namespace: 'foo-ns' }, - { ...obj2, namespace: 'bar-ns' }, - ]; - const namespaces = [undefined, 'foo-ns', 'bar-ns']; - const options = {}; // use the default namespace for the options - await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespaces); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectObjectsNamespaceFiltering(client.bulkUpdate, { objects, options }); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; - clientOpts.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); - const objects = [obj1, obj2]; - const options = { namespace }; - await expectSuccess(client.bulkUpdate, { objects, options }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_update', 'unknown', { type: obj1.type, id: obj1.id }); - expectAuditEvent('saved_object_update', 'unknown', { type: obj2.type, id: obj2.id }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.bulkUpdate([obj1, obj2], { namespace })).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_update', 'failure', { type: obj1.type, id: obj1.id }); - expectAuditEvent('saved_object_update', 'failure', { type: obj2.type, id: obj2.id }); - }); -}); - -describe('#bulkDelete', () => { - const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); - const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const objects = [obj1]; - await expectGeneralError(client.bulkDelete, { objects }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectForbiddenError(client.bulkDelete, { objects, options }); - }); - - test(`returns result of baseClient.bulkDelete when authorized`, async () => { - const apiCallReturnValue = { - statuses: [obj1, obj2].map((obj) => { - return { ...obj, success: true }; - }), - }; - clientOpts.baseClient.bulkDelete.mockReturnValue(apiCallReturnValue as any); - - const objects = [obj1, obj2]; - const options = { namespace }; - const result = await expectSuccess(client.bulkDelete, { objects, options }); - expect(result).toEqual(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectPrivilegeCheck(client.bulkDelete, { objects, options }, namespace); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = { - statuses: [obj1, obj2].map((obj) => { - return { ...obj, success: true }; - }), - }; - clientOpts.baseClient.bulkDelete.mockReturnValue(apiCallReturnValue as any); - - const objects = [obj1, obj2]; - const options = { namespace }; - await expectSuccess(client.bulkDelete, { objects, options }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_delete', 'success', { type: obj1.type, id: obj1.id }); - expectAuditEvent('saved_object_delete', 'success', { type: obj2.type, id: obj2.id }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.bulkDelete([obj1, obj2], { namespace })).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_delete', 'failure', { type: obj1.type, id: obj1.id }); - expectAuditEvent('saved_object_delete', 'failure', { type: obj2.type, id: obj2.id }); - }); -}); - -describe('#checkConflicts', () => { - const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); - const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { - const objects = [obj1, obj2]; - await expectGeneralError(client.checkConflicts, { objects }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectForbiddenError(client.checkConflicts, { objects, options }, 'checkConflicts'); - }); - - test(`returns result of baseClient.create when authorized`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.checkConflicts.mockResolvedValue(apiCallReturnValue as any); - - const objects = [obj1, obj2]; - const options = { namespace }; - const result = await expectSuccess( - client.checkConflicts, - { objects, options }, - 'checkConflicts' - ); - expect(result).toBe(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - const objects = [obj1, obj2]; - const options = { namespace }; - await expectPrivilegeCheck(client.checkConflicts, { objects, options }, namespace); - }); -}); - -describe('#create', () => { - const type = 'foo'; - const attributes = { some_attr: 's' }; - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { - await expectGeneralError(client.create, { type }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const options = { id: 'mock-saved-object-id', namespace }; - await expectForbiddenError(client.create, { type, attributes, options }); - }); - - test(`returns result of baseClient.create when authorized`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); - - const options = { id: 'mock-saved-object-id', namespace }; - const result = await expectSuccess(client.create, { - type, - attributes, - options, - }); - expect(result).toBe(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - const options = { namespace }; - await expectPrivilegeCheck(client.create, { type, attributes, options }, [namespace]); - }); - - test(`checks privileges for user, actions, namespace, and initialNamespaces`, async () => { - const options = { namespace, initialNamespaces: ['another-ns', 'yet-another-ns'] }; - await expectPrivilegeCheck(client.create, { type, attributes, options }, [ - namespace, - 'another-ns', - 'yet-another-ns', - ]); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - const options = { namespace }; - await expectObjectNamespaceFiltering(client.create, { type, attributes, options }); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); - const options = { id: 'mock-saved-object-id', namespace }; - await expectSuccess(client.create, { type, attributes, options }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_create', 'unknown', { type, id: expect.any(String) }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.create(type, attributes, { namespace })).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_create', 'failure', { type, id: expect.any(String) }); - }); -}); - -describe('#delete', () => { - const type = 'foo'; - const id = `${type}-id`; - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - await expectGeneralError(client.delete, { type, id }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const options = { namespace }; - await expectForbiddenError(client.delete, { type, id, options }); - }); - - test(`returns result of internalRepository.delete when authorized`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.delete.mockReturnValue(apiCallReturnValue as any); - - const options = { namespace }; - const result = await expectSuccess(client.delete, { type, id, options }); - expect(result).toBe(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - const options = { namespace }; - await expectPrivilegeCheck(client.delete, { type, id, options }, namespace); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.delete.mockReturnValue(apiCallReturnValue as any); - const options = { namespace }; - await expectSuccess(client.delete, { type, id, options }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_delete', 'unknown', { type, id }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.delete(type, id)).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_delete', 'failure', { type, id }); - }); -}); - -describe('#find', () => { - const type1 = 'foo'; - const type2 = 'bar'; - const namespaces = ['some-ns']; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - await expectGeneralError(client.find, { type: type1 }); - }); - - test(`returns empty result when unauthorized`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure - ); - - const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); - const result = await client.find(options); - - expect(clientOpts.baseClient.find).not.toHaveBeenCalled(); - expect(result).toEqual({ page: 1, per_page: 20, total: 0, saved_objects: [] }); - }); - - test(`returns result of baseClient.find when fully authorized`, async () => { - const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; - clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); - - const options = { type: type1, namespaces }; - const result = await expectSuccess(client.find, { options }); - expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({ - ...options, - typeToNamespacesMap: undefined, - }); - expect(result).toEqual(apiCallReturnValue); - }); - - test(`returns result of baseClient.find when partially authorized`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username: USERNAME, - privileges: { - kibana: [ - { resource: 'some-ns', privilege: 'mock-saved_object:foo/find', authorized: true }, - { resource: 'some-ns', privilege: 'mock-saved_object:bar/find', authorized: true }, - { resource: 'some-ns', privilege: 'mock-saved_object:baz/find', authorized: false }, - { resource: 'some-ns', privilege: 'mock-saved_object:qux/find', authorized: false }, - { resource: 'another-ns', privilege: 'mock-saved_object:foo/find', authorized: true }, - { resource: 'another-ns', privilege: 'mock-saved_object:bar/find', authorized: false }, - { resource: 'another-ns', privilege: 'mock-saved_object:baz/find', authorized: true }, - { resource: 'another-ns', privilege: 'mock-saved_object:qux/find', authorized: false }, - { resource: 'forbidden-ns', privilege: 'mock-saved_object:foo/find', authorized: false }, - { resource: 'forbidden-ns', privilege: 'mock-saved_object:bar/find', authorized: false }, - { resource: 'forbidden-ns', privilege: 'mock-saved_object:baz/find', authorized: false }, - { resource: 'forbidden-ns', privilege: 'mock-saved_object:qux/find', authorized: false }, - ], - }, - }); - - const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; - clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); - - const options = Object.freeze({ - type: ['foo', 'bar', 'baz', 'qux'], - namespaces: ['some-ns', 'another-ns', 'forbidden-ns'], - }); - const result = await client.find(options); - // 'expect(clientOpts.baseClient.find).toHaveBeenCalledWith' resulted in false negatives, resorting to manually comparing mock call args - expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({ - ...options, - typeToNamespacesMap: new Map([ - ['foo', ['some-ns', 'another-ns']], - ['bar', ['some-ns']], - ['baz', ['another-ns']], - // qux is not authorized, so there is no entry for it - // forbidden-ns is completely forbidden, so there are no entries with this namespace - ]), - type: '', - namespaces: [], - }); - expect(result).toEqual(apiCallReturnValue); - }); - - test(`throws BadRequestError when searching across namespaces when spaces is disabled`, async () => { - clientOpts = createSecureSavedObjectsClientWrapperOptions(); - clientOpts.getSpacesService.mockReturnValue(undefined); - client = new SecureSavedObjectsClientWrapper(clientOpts); - - // succeed privilege checks by default - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesSuccess - ); - - const options = { type: [type1, type2], namespaces }; - await expect(client.find(options)).rejects.toThrowErrorMatchingInlineSnapshot( - `"_find across namespaces is not permitted when the Spaces plugin is disabled."` - ); - }); - - test(`checks privileges for user, actions, and namespaces`, async () => { - const options = { type: [type1, type2], namespaces }; - await expectPrivilegeCheck(client.find, { options }, namespaces); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - const options = { type: [type1, type2], namespaces }; - await expectObjectsNamespaceFiltering(client.find, { options }); - }); - - test(`adds audit event when successful`, async () => { - const obj1 = { type: 'foo', id: 'sup' }; - const obj2 = { type: 'bar', id: 'everyone' }; - const apiCallReturnValue = { saved_objects: [obj1, obj2], foo: 'bar' }; - clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); - const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); - await expectSuccess(client.find, { options }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); - expectAuditEvent('saved_object_find', 'success', obj1); - expectAuditEvent('saved_object_find', 'success', obj2); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure - ); - await client.find({ type: type1 }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_find', 'failure'); - }); -}); - -describe('#get', () => { - const type = 'foo'; - const id = `${type}-id`; - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - await expectGeneralError(client.get, { type, id }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const options = { namespace }; - await expectForbiddenError(client.get, { type, id, options }); - }); - - test(`returns result of baseClient.get when authorized`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.get.mockReturnValue(apiCallReturnValue as any); - - const options = { namespace }; - const result = await expectSuccess(client.get, { type, id, options }); - expect(result).toBe(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - const options = { namespace }; - await expectPrivilegeCheck(client.get, { type, id, options }, namespace); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - const options = { namespace }; - await expectObjectNamespaceFiltering(client.get, { type, id, options }); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.get.mockReturnValue(apiCallReturnValue as any); - const options = { namespace }; - await expectSuccess(client.get, { type, id, options }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_get', 'success', { type, id }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.get(type, id, { namespace })).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_get', 'failure', { type, id }); - }); -}); - -describe('#openPointInTimeForType', () => { - const type = 'foo'; - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - await expectGeneralError(client.openPointInTimeForType, { type }); - }); - - test(`returns result of baseClient.openPointInTimeForType when authorized`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.openPointInTimeForType.mockReturnValue(apiCallReturnValue as any); - - const options = { namespaces: [namespace] }; - const result = await expectSuccess(client.openPointInTimeForType, { type, options }); - expect(result).toBe(apiCallReturnValue); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.openPointInTimeForType.mockReturnValue(apiCallReturnValue as any); - const options = { namespaces: [namespace] }; - await expectSuccess(client.openPointInTimeForType, { type, options }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_open_point_in_time', 'unknown'); - }); - - test(`throws an error when unauthorized`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure - ); - const options = { namespaces: [namespace] }; - await expect(() => client.openPointInTimeForType(type, options)).rejects.toThrowError( - 'unauthorized' - ); - }); - - test(`adds audit event when unauthorized`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure - ); - const options = { namespaces: [namespace] }; - await expect(() => client.openPointInTimeForType(type, options)).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_open_point_in_time', 'failure'); - }); - - test(`filters types based on authorization`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username: USERNAME, - privileges: { - kibana: [ - { - resource: 'some-ns', - privilege: 'mock-saved_object:foo/open_point_in_time', - authorized: true, - }, - { - resource: 'some-ns', - privilege: 'mock-saved_object:bar/open_point_in_time', - authorized: true, - }, - { - resource: 'some-ns', - privilege: 'mock-saved_object:baz/open_point_in_time', - authorized: false, - }, - { - resource: 'some-ns', - privilege: 'mock-saved_object:qux/open_point_in_time', - authorized: false, - }, - { - resource: 'another-ns', - privilege: 'mock-saved_object:foo/open_point_in_time', - authorized: true, - }, - { - resource: 'another-ns', - privilege: 'mock-saved_object:bar/open_point_in_time', - authorized: false, - }, - { - resource: 'another-ns', - privilege: 'mock-saved_object:baz/open_point_in_time', - authorized: true, - }, - { - resource: 'another-ns', - privilege: 'mock-saved_object:qux/open_point_in_time', - authorized: false, - }, - { - resource: 'forbidden-ns', - privilege: 'mock-saved_object:foo/open_point_in_time', - authorized: false, - }, - { - resource: 'forbidden-ns', - privilege: 'mock-saved_object:bar/open_point_in_time', - authorized: false, - }, - { - resource: 'forbidden-ns', - privilege: 'mock-saved_object:baz/open_point_in_time', - authorized: false, - }, - { - resource: 'forbidden-ns', - privilege: 'mock-saved_object:qux/open_point_in_time', - authorized: false, - }, - ], - }, - }); - - await client.openPointInTimeForType(['foo', 'bar', 'baz', 'qux'], { - namespaces: ['some-ns', 'another-ns', 'forbidden-ns'], - }); - - expect(clientOpts.baseClient.openPointInTimeForType).toHaveBeenCalledWith( - ['foo', 'bar', 'baz'], - { - namespaces: ['some-ns', 'another-ns', 'forbidden-ns'], - } - ); - }); -}); - -describe('#closePointInTime', () => { - const id = 'abc123'; - const namespace = 'some-ns'; - - test(`returns result of baseClient.closePointInTime`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.closePointInTime.mockReturnValue(apiCallReturnValue as any); - - const options = { namespace }; - const result = await client.closePointInTime(id, options); - expect(result).toBe(apiCallReturnValue); - }); - - test(`adds audit event`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.closePointInTime.mockReturnValue(apiCallReturnValue as any); - - const options = { namespace }; - await client.closePointInTime(id, options); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_close_point_in_time', 'unknown'); - }); -}); - -describe('#createPointInTimeFinder', () => { - it('redirects request to underlying base client with default dependencies', () => { - const options = { type: ['a', 'b'], search: 'query' }; - client.createPointInTimeFinder(options); - - expect(clientOpts.baseClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); - expect(clientOpts.baseClient.createPointInTimeFinder).toHaveBeenCalledWith(options, { - client, - }); - }); - - it('redirects request to underlying base client with custom dependencies', () => { - const options = { type: ['a', 'b'], search: 'query' }; - const dependencies = { - client: { - find: jest.fn(), - openPointInTimeForType: jest.fn(), - closePointInTime: jest.fn(), - }, - }; - client.createPointInTimeFinder(options, dependencies); - - expect(clientOpts.baseClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); - expect(clientOpts.baseClient.createPointInTimeFinder).toHaveBeenCalledWith( - options, - dependencies - ); - }); -}); - -describe('#resolve', () => { - const type = 'foo'; - const id = `${type}-id`; - const namespace = 'some-ns'; - const resolvedId = 'another-id'; // success audit records include the resolved ID, not the requested ID - const mockResult = { saved_object: { id: resolvedId } }; // mock result needs to have ID for audit logging - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - await expectGeneralError(client.resolve, { type, id }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const options = { namespace }; - await expectForbiddenError(client.resolve, { type, id, options }, 'resolve'); - }); - - test(`returns result of baseClient.resolve when authorized`, async () => { - const apiCallReturnValue = mockResult; - clientOpts.baseClient.resolve.mockReturnValue(apiCallReturnValue as any); - - const options = { namespace }; - const result = await expectSuccess(client.resolve, { type, id, options }, 'resolve'); - expect(result).toEqual(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - const options = { namespace }; - await expectPrivilegeCheck(client.resolve, { type, id, options }, namespace); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - const options = { namespace }; - - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( - getMockCheckPrivilegesSuccess // privilege check for authorization - ); - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure // privilege check for namespace filtering - ); - - const namespaces = ['some-other-namespace', '*', namespace]; - const returnValue = { saved_object: { namespaces, id: resolvedId, foo: 'bar' } }; - clientOpts.baseClient.resolve.mockReturnValue(returnValue as any); - - const result = await client.resolve(type, id, options); - // we will never redact the "All Spaces" ID - expect(result).toEqual({ - saved_object: expect.objectContaining({ namespaces: ['*', namespace, '?'] }), - }); - - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( - 'login:', - ['some-other-namespace'] - // when we check what namespaces to redact, we don't check privileges for '*', only actual space IDs - // we don't check privileges for authorizedNamespace either, as that was already checked earlier in the operation - ); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = mockResult; - clientOpts.baseClient.resolve.mockReturnValue(apiCallReturnValue as any); - const options = { namespace }; - await expectSuccess(client.resolve, { type, id, options }, 'resolve'); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_resolve', 'success', { type, id: resolvedId }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.resolve(type, id, { namespace })).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_resolve', 'failure', { type, id }); - }); -}); - -describe('#update', () => { - const type = 'foo'; - const id = `${type}-id`; - const attributes = { some: 'attr' }; - const namespace = 'some-ns'; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - await expectGeneralError(client.update, { type, id, attributes }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const options = { namespace }; - await expectForbiddenError(client.update, { type, id, attributes, options }); - }); - - test(`returns result of baseClient.update when authorized`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.update.mockReturnValue(apiCallReturnValue as any); - - const options = { namespace }; - const result = await expectSuccess(client.update, { type, id, attributes, options }); - expect(result).toBe(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - const options = { namespace }; - await expectPrivilegeCheck(client.update, { type, id, attributes, options }, namespace); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - const options = { namespace }; - await expectObjectNamespaceFiltering(client.update, { type, id, attributes, options }); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.update.mockReturnValue(apiCallReturnValue as any); - const options = { namespace }; - await expectSuccess(client.update, { type, id, attributes, options }); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_update', 'unknown', { type, id }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.update(type, id, attributes, { namespace })).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_update', 'failure', { type, id }); - }); -}); - -describe('#removeReferencesTo', () => { - const type = 'foo'; - const id = `${type}-id`; - const namespace = 'some-ns'; - const options = { namespace }; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - await expectGeneralError(client.removeReferencesTo, { type, id, options }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - await expectForbiddenError( - client.removeReferencesTo, - { type, id, options }, - 'removeReferences' - ); - }); - - test(`returns result of baseClient.removeReferencesTo when authorized`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.removeReferencesTo.mockReturnValue(apiCallReturnValue as any); - - const result = await expectSuccess( - client.removeReferencesTo, - { type, id, options }, - 'removeReferences' - ); - expect(result).toBe(apiCallReturnValue); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - await expectPrivilegeCheck(client.removeReferencesTo, { type, id, options }, namespace); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.removeReferencesTo.mockReturnValue(apiCallReturnValue as any); - await client.removeReferencesTo(type, id); - - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_remove_references', 'unknown', { type, id }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.removeReferencesTo(type, id)).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_remove_references', 'failure', { type, id }); - }); -}); - -/** - * Naming conventions used in this group of tests: - * * 'reqObj' is an object that the consumer requests (SavedObjectsCollectMultiNamespaceReferencesObject) - * * 'obj' is the object result that was fetched from Elasticsearch (SavedObjectReferenceWithContext) - */ -describe('#collectMultiNamespaceReferences', () => { - const AUDIT_ACTION = 'saved_object_collect_multinamespace_references'; - const spaceX = 'space-x'; - const spaceY = 'space-y'; - const spaceZ = 'space-z'; - - /** Returns a valid inboundReferences field for mock baseClient results. */ - function getInboundRefsFrom( - ...objects: Array<{ type: string; id: string }> - ): Pick { - return { - inboundReferences: objects.map(({ type, id }) => { - return { type, id, name: `ref-${type}:${id}` }; - }), - }; - } - - beforeEach(() => { - // by default, the result is a success, each object exists in the current space and another space - clientOpts.baseClient.collectMultiNamespaceReferences.mockImplementation((objects) => - Promise.resolve({ - objects: objects.map(({ type, id }) => ({ - type, - id, - spaces: [spaceX, spaceY, spaceZ], - inboundReferences: [], - })), - }) - ); - }); - - describe('errors', () => { - const reqObj1 = { type: 'a', id: '1' }; - const reqObj2 = { type: 'b', id: '2' }; - const reqObj3 = { type: 'c', id: '3' }; - - test(`throws an error if the base client operation fails`, async () => { - clientOpts.baseClient.collectMultiNamespaceReferences.mockRejectedValue(new Error('Oh no!')); - await expect(() => - client.collectMultiNamespaceReferences([reqObj1], { namespace: spaceX }) - ).rejects.toThrowError('Oh no!'); - expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).not.toHaveBeenCalled(); - expect(clientOpts.auditLogger.log).not.toHaveBeenCalled(); - }); - - describe(`throws decorated ForbiddenError and adds audit events when unauthorized`, () => { - test(`with purpose 'collectMultiNamespaceReferences'`, async () => { - // Use the default mocked results for the base client call. - // This fails because the user is not authorized to bulk_get type 'c' in the current space. - mockEnsureAuthorized.mockResolvedValue({ - status: 'partially_authorized', - typeActionMap: new Map() - .set('a', { bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] } }) - .set('b', { bulk_get: { authorizedSpaces: [spaceX, spaceY] } }) - .set('c', { bulk_get: { authorizedSpaces: [spaceY] } }), - }); - const options = { namespace: spaceX }; // spaceX is the current space - await expect(() => - client.collectMultiNamespaceReferences([reqObj1, reqObj2, reqObj3], options) - ).rejects.toThrowError(clientOpts.forbiddenError); - expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(3); - expectAuditEvent(AUDIT_ACTION, 'failure', reqObj1); - expectAuditEvent(AUDIT_ACTION, 'failure', reqObj2); - expectAuditEvent(AUDIT_ACTION, 'failure', reqObj3); - }); - - test(`with purpose 'updateObjectsSpaces'`, async () => { - // Use the default mocked results for the base client call. - // This fails because the user is not authorized to share_to_space type 'c' in the current space. - mockEnsureAuthorized.mockResolvedValue({ - status: 'partially_authorized', - typeActionMap: new Map() - .set('a', { - bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, - share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, - }) - .set('b', { - bulk_get: { authorizedSpaces: [spaceX, spaceY] }, - share_to_space: { authorizedSpaces: [spaceX, spaceY] }, - }) - .set('c', { - bulk_get: { authorizedSpaces: [spaceX, spaceY] }, - share_to_space: { authorizedSpaces: [spaceY] }, - }), - }); - const options = { namespace: spaceX, purpose: 'updateObjectsSpaces' as const }; // spaceX is the current space - await expect(() => - client.collectMultiNamespaceReferences([reqObj1, reqObj2, reqObj3], options) - ).rejects.toThrowError(clientOpts.forbiddenError); - expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(3); - expectAuditEvent(AUDIT_ACTION, 'failure', reqObj1); - expectAuditEvent(AUDIT_ACTION, 'failure', reqObj2); - expectAuditEvent(AUDIT_ACTION, 'failure', reqObj3); - }); - }); - - test(`throws an error if the base client result includes a requested object without a valid inbound reference`, async () => { - // We *shouldn't* ever get an inbound reference that is not also present in the base client response objects array. - const spaces = [spaceX]; - - const obj1 = { ...reqObj1, spaces, inboundReferences: [] }; - const obj2 = { - type: 'a', - id: '2', - spaces, - ...getInboundRefsFrom({ type: 'some-type', id: 'some-id' }), - }; - clientOpts.baseClient.collectMultiNamespaceReferences.mockResolvedValueOnce({ - objects: [obj1, obj2], - }); - mockEnsureAuthorized.mockResolvedValue({ - status: 'partially_authorized', - typeActionMap: new Map().set('a', { - bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, - }), - }); - // When the loop gets to obj2, it will determine that the user is authorized for the object but *not* for the graph. However, it will - // also determine that there is *no* valid inbound reference tying this object back to what was requested. In this case, throw an - // error. - - const options = { namespace: spaceX }; // spaceX is the current space - await expect(() => - client.collectMultiNamespaceReferences([reqObj1], options) - ).rejects.toThrowError('Unexpected inbound reference to "some-type:some-id"'); - }); - }); - - describe(`checks privileges`, () => { - // Other test cases below contain more complex assertions for privilege checks, but these focus on the current space (default vs non-default) - const reqObj1 = { type: 'a', id: '1' }; - const obj1 = { ...reqObj1, spaces: ['*'], inboundReferences: [] }; - - beforeEach(() => { - clientOpts.baseClient.collectMultiNamespaceReferences.mockResolvedValueOnce({ - objects: [obj1], - }); - mockEnsureAuthorized.mockResolvedValue({ - status: 'fully_authorized', - typeActionMap: new Map().set('a', { - bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, // success case for the simplest test - }), - }); - }); - - test(`in the default space`, async () => { - await client.collectMultiNamespaceReferences([reqObj1]); - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledWith( - expect.any(Object), // dependencies - ['a'], // unique types of the fetched objects - ['bulk_get'], // actions - ['default'], // unique spaces that the fetched objects exist in, along with the current space - { requireFullAuthorization: false } - ); - }); - - test(`in a non-default space`, async () => { - await client.collectMultiNamespaceReferences([reqObj1], { namespace: spaceX }); - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledWith( - expect.any(Object), // dependencies - ['a'], // unique types of the fetched objects - ['bulk_get'], // actions - [spaceX], // unique spaces that the fetched objects exist in, along with the current space - { requireFullAuthorization: false } - ); - }); - }); - - describe(`checks privileges, filters/redacts objects correctly, and records audit events`, () => { - const reqObj1 = { type: 'a', id: '1' }; - const reqObj2 = { type: 'b', id: '2' }; - const spaces = [spaceX, spaceY, spaceZ]; - const spacesWithMatchingAliases = [spaceX, spaceY, spaceZ]; - const spacesWithMatchingOrigins = [spaceX, spaceY, spaceZ]; - - // Actual object graph: - // ─► obj1 (a:1) ─┬─► obj3 (c:3) ───► obj5 (c:5) ─► obj8 (c:8) ─┐ - // │ ▲ │ - // │ │ │ - // └─► obj4 (d:4) ─┬─► obj6 (c:6) ◄──────────────┘ - // ─► obj2 (b:2) └─► obj7 (c:7) - // - // Object graph that the consumer sees after authorization: - // ─► obj1 (a:1) ─┬─► obj3 (c:3) ───► obj5 (c:5) ─► obj8 (c:8) ─► obj6 (c:6) ─┐ - // │ ▲ │ - // │ └───────────────────────────────────┘ - // └─► obj4 (d:4) - // ─► obj2 (b:2) - const obj1 = { - ...reqObj1, - spaces, - inboundReferences: [], - // We include spacesWithMatchingAliases and spacesWithMatchingOrigins on this object of type 'a' (which the user is authorized to access globally) to assert that they are not redacted - spacesWithMatchingAliases, - spacesWithMatchingOrigins, - }; - const obj2 = { ...reqObj2, spaces: [], inboundReferences: [] }; // non-multi-namespace types and hidden types will be returned with an empty spaces array - const obj3 = { - type: 'c', - id: '3', - spaces, - ...getInboundRefsFrom(obj1), - // We include spacesWithMatchingAliases and spacesWithMatchingOrigins on this object of type 'c' (which the user is partially authorized for) to assert that they are redacted - spacesWithMatchingAliases, - spacesWithMatchingOrigins, - }; - const obj4 = { type: 'd', id: '4', spaces, ...getInboundRefsFrom(obj1) }; - const obj5 = { - type: 'c', - id: '5', - spaces: ['*'], - ...getInboundRefsFrom(obj3, { type: 'c', id: '6' }), - }; - const obj6 = { - type: 'c', - id: '6', - spaces, - ...getInboundRefsFrom(obj4, { type: 'c', id: '8' }), - }; - const obj7 = { type: 'c', id: '7', spaces, ...getInboundRefsFrom(obj4) }; - const obj8 = { type: 'c', id: '8', spaces, ...getInboundRefsFrom(obj5) }; - - beforeEach(() => { - clientOpts.baseClient.collectMultiNamespaceReferences.mockResolvedValueOnce({ - objects: [obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8], - }); - }); - - test(`with purpose 'collectMultiNamespaceReferences'`, async () => { - mockEnsureAuthorized.mockResolvedValue({ - status: 'partially_authorized', - typeActionMap: new Map() - .set('a', { bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] } }) - .set('b', { bulk_get: { authorizedSpaces: [spaceX] } }) - .set('c', { bulk_get: { authorizedSpaces: [spaceX] } }), - // the user is not authorized to read type 'd' - }); - - const options = { namespace: spaceX }; // spaceX is the current space - const result = await client.collectMultiNamespaceReferences([reqObj1, reqObj2], options); - expect(result).toEqual({ - objects: [ - obj1, // obj1's spaces, spacesWithMatchingAliases, and spacesWithMatchingOrigins arrays are not redacted because the user is globally authorized to access it - obj2, // obj2 has an empty spaces array (see above) - { - ...obj3, - spaces: [spaceX, '?', '?'], - spacesWithMatchingAliases: [spaceX, '?', '?'], - spacesWithMatchingOrigins: [spaceX, '?', '?'], - }, - { ...obj4, spaces: [], isMissing: true }, // obj4 is marked as Missing because the user was not authorized to access it - obj5, // obj5's spaces array is not redacted, because it exists in All Spaces - // obj7 is not included at all because the user was not authorized to access its inbound reference (obj4) - { ...obj8, spaces: [spaceX, '?', '?'] }, - { ...obj6, spaces: [spaceX, '?', '?'], ...getInboundRefsFrom(obj8) }, // obj6 is at the back of the list and its inboundReferences array is redacted because the user is not authorized to access one of its inbound references, obj4 - ], - }); - expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); - expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledWith( - [reqObj1, reqObj2], - options - ); - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledWith( - expect.any(Object), // dependencies - ['a', 'b', 'c', 'd'], // unique types of the fetched objects - ['bulk_get'], // actions - [spaceX, spaceY, spaceZ], // unique spaces that the fetched objects exist in, along with the current space - { requireFullAuthorization: false } - ); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(5); - expectAuditEvent(AUDIT_ACTION, 'success', obj1); - expectAuditEvent(AUDIT_ACTION, 'success', obj3); - expectAuditEvent(AUDIT_ACTION, 'success', obj5); - expectAuditEvent(AUDIT_ACTION, 'success', obj8); - expectAuditEvent(AUDIT_ACTION, 'success', obj6); - // obj2, obj4, and obj7 are intentionally excluded from the audit record because we did not return any information about them to the user - }); - - test(`with purpose 'updateObjectsSpaces'`, async () => { - mockEnsureAuthorized.mockResolvedValue({ - status: 'partially_authorized', - typeActionMap: new Map() - .set('a', { - share_to_space: { authorizedSpaces: [spaceX] }, - bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, - // Even though the user can only share type 'a' in spaceX, we won't redact spaceY or spaceZ because the user has global read privileges - }) - .set('b', { - share_to_space: { authorizedSpaces: [spaceX] }, - bulk_get: { authorizedSpaces: [spaceX, spaceY] }, - }) - .set('c', { - share_to_space: { authorizedSpaces: [spaceX] }, - bulk_get: { authorizedSpaces: [spaceX, spaceY] }, - // Even though the user can only share type 'c' in spaceX, we won't redact spaceY because the user has read privileges there - }), - // the user is not authorized to read or share type 'd' - }); - - const options = { namespace: spaceX, purpose: 'updateObjectsSpaces' as const }; // spaceX is the current space - const result = await client.collectMultiNamespaceReferences([reqObj1, reqObj2], options); - expect(result).toEqual({ - objects: [ - obj1, // obj1's spaces, spacesWithMatchingAliases, and spacesWithMatchingOrigins arrays are not redacted because the user is globally authorized to access it - obj2, // obj2 has an empty spaces array (see above) - { - ...obj3, - spaces: [spaceX, spaceY, '?'], - spacesWithMatchingAliases: [spaceX, spaceY, '?'], - spacesWithMatchingOrigins: [spaceX, spaceY, '?'], - }, - { ...obj4, spaces: [], isMissing: true }, // obj4 is marked as Missing because the user was not authorized to access it - obj5, // obj5's spaces array is not redacted, because it exists in All Spaces - // obj7 is not included at all because the user was not authorized to access its inbound reference (obj4) - { ...obj8, spaces: [spaceX, spaceY, '?'] }, - { ...obj6, spaces: [spaceX, spaceY, '?'], ...getInboundRefsFrom(obj8) }, // obj6 is at the back of the list and its inboundReferences array is redacted because the user is not authorized to access one of its inbound references, obj4 - ], - }); - expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); - expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledWith( - [reqObj1, reqObj2], - options - ); - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledWith( - expect.any(Object), // dependencies - ['a', 'b', 'c', 'd'], // unique types of the fetched objects - ['bulk_get', 'share_to_space'], // actions - [spaceX, spaceY, spaceZ], // unique spaces that the fetched objects exist in, along with the current space - { requireFullAuthorization: false } - ); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(5); - expectAuditEvent(AUDIT_ACTION, 'success', obj1); - expectAuditEvent(AUDIT_ACTION, 'success', obj3); - expectAuditEvent(AUDIT_ACTION, 'success', obj5); - expectAuditEvent(AUDIT_ACTION, 'success', obj8); - expectAuditEvent(AUDIT_ACTION, 'success', obj6); - // obj2, obj4, and obj7 are intentionally excluded from the audit record because we did not return any information about them to the user - }); - }); -}); - -describe('#updateObjectsSpaces', () => { - const AUDIT_ACTION = 'saved_object_update_objects_spaces'; - const spaceA = 'space-a'; - const spaceB = 'space-b'; - const spaceC = 'space-c'; - const spaceD = 'space-d'; - const obj1 = { type: 'x', id: '1' }; - const obj2 = { type: 'y', id: '2' }; - const obj3 = { type: 'z', id: '3' }; - const obj4 = { type: 'z', id: '4' }; - const obj5 = { type: 'z', id: '5' }; - - describe('errors', () => { - test(`throws an error if the base client bulkGet operation fails`, async () => { - clientOpts.baseClient.bulkGet.mockRejectedValue(new Error('Oh no!')); - await expect(() => - client.updateObjectsSpaces([obj1], [spaceA], [spaceB], { namespace: spaceC }) - ).rejects.toThrowError('Oh no!'); - expect(clientOpts.baseClient.bulkGet).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).not.toHaveBeenCalled(); - expect(clientOpts.auditLogger.log).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError and adds audit events when unauthorized`, async () => { - clientOpts.baseClient.bulkGet.mockResolvedValue({ - saved_objects: [ - { ...obj1, namespaces: [spaceB, spaceC, spaceD] }, - { ...obj2, namespaces: [spaceB, spaceC, spaceD] }, - { ...obj3, namespaces: [spaceB, spaceC, spaceD] }, - ] as SavedObject[], - }); - // This fails because the user is not authorized to share_to_space type 'z' in the current space. - mockEnsureAuthorized.mockResolvedValue({ - status: 'partially_authorized', - typeActionMap: new Map() - .set('x', { - bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, - share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, - }) - .set('y', { - bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC, spaceD] }, - share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC, spaceD] }, - }) - .set('z', { - bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC] }, - share_to_space: { authorizedSpaces: [spaceA, spaceB] }, - }), - }); - - const objects = [obj1, obj2, obj3]; - const spacesToAdd = [spaceA]; - const spacesToRemove = [spaceB]; - const options = { namespace: spaceC }; // spaceC is the current space - await expect(() => - client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options) - ).rejects.toThrowError(clientOpts.forbiddenError); - expect(clientOpts.baseClient.bulkGet).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(3); - expectAuditEvent(AUDIT_ACTION, 'failure', obj1); - expectAuditEvent(AUDIT_ACTION, 'failure', obj2); - expectAuditEvent(AUDIT_ACTION, 'failure', obj3); - expect(clientOpts.baseClient.updateObjectsSpaces).not.toHaveBeenCalled(); - }); - - test(`throws an error if the base client updateObjectsSpaces operation fails`, async () => { - clientOpts.baseClient.bulkGet.mockResolvedValue({ - saved_objects: [ - { ...obj1, namespaces: [spaceB, spaceC, spaceD] }, - { ...obj2, namespaces: [spaceB, spaceC, spaceD] }, - { ...obj3, namespaces: [spaceB, spaceC, spaceD] }, - ] as SavedObject[], - }); - mockEnsureAuthorized.mockResolvedValue({ - status: 'partially_authorized', - typeActionMap: new Map() - .set('x', { - bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, - share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, - }) - .set('y', { - bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC, spaceD] }, - share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC] }, - }) - .set('z', { - bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC] }, - share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC] }, - }), - }); - clientOpts.baseClient.updateObjectsSpaces.mockRejectedValue(new Error('Oh no!')); - - const objects = [obj1, obj2, obj3]; - const spacesToAdd = [spaceA]; - const spacesToRemove = [spaceB]; - const options = { namespace: spaceC }; // spaceC is the current space - await expect(() => - client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options) - ).rejects.toThrowError('Oh no!'); - expect(clientOpts.baseClient.bulkGet).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(3); - expectAuditEvent(AUDIT_ACTION, 'unknown', obj1); - expectAuditEvent(AUDIT_ACTION, 'unknown', obj2); - expectAuditEvent(AUDIT_ACTION, 'unknown', obj3); - expect(clientOpts.baseClient.updateObjectsSpaces).toHaveBeenCalledTimes(1); - }); - }); - - test(`checks privileges, filters/redacts objects correctly, and records audit events`, async () => { - const bulkGetResults = [ - { ...obj1, namespaces: [spaceB, spaceC, spaceD], version: 'v1' }, - { ...obj2, namespaces: [spaceB, spaceC, spaceD], version: 'v2' }, - { ...obj3, namespaces: [spaceB, spaceC, spaceD], version: 'v3' }, - { ...obj4, namespaces: ['*'], version: 'v4' }, // obj4 exists in all spaces - { ...obj5, namespaces: [spaceB, spaceC, spaceD], version: 'v5' }, - ] as SavedObject[]; - clientOpts.baseClient.bulkGet.mockResolvedValue({ saved_objects: bulkGetResults }); - mockEnsureAuthorized.mockResolvedValue({ - status: 'partially_authorized', - typeActionMap: new Map() - .set('x', { - bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, - share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, - }) - .set('y', { - bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC, spaceD] }, - share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC] }, - }) - .set('z', { - bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC] }, // the user is not authorized to bulkGet type 'z' in spaceD, so it will be redacted from the results - share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC] }, - }), - }); - clientOpts.baseClient.updateObjectsSpaces.mockResolvedValue({ - objects: [ - // Each object was added to spaceA and removed from spaceB - { ...obj1, spaces: [spaceA, spaceC, spaceD] }, - { ...obj2, spaces: [spaceA, spaceC, spaceD] }, - { ...obj3, spaces: [spaceA, spaceC, spaceD] }, - { ...obj4, spaces: ['*', spaceA] }, // even though this object exists in all spaces, we won't pass '*' to ensureAuthorized - { ...obj5, spaces: [], error: new Error('Oh no!') }, // we encountered an error when attempting to update obj5 - ] as SavedObjectsUpdateObjectsSpacesResponseObject[], - }); - - const objects = [obj1, obj2, obj3, obj4, obj5]; - const spacesToAdd = [spaceA]; - const spacesToRemove = [spaceB]; - const options = { namespace: spaceC }; // spaceC is the current space - const result = await client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options); - expect(result).toEqual({ - objects: [ - { ...obj1, spaces: [spaceA, spaceC, spaceD] }, // obj1's spaces array is not redacted because the user is globally authorized to access it - { ...obj2, spaces: [spaceA, spaceC, spaceD] }, // obj2's spaces array is not redacted because the user is authorized to access it in each space - { ...obj3, spaces: [spaceA, spaceC, '?'] }, // obj3's spaces array is redacted because the user is not authorized to access it in spaceD - { ...obj4, spaces: ['*', spaceA] }, - { ...obj5, spaces: [], error: new Error('Oh no!') }, - ], - }); - - expect(clientOpts.baseClient.bulkGet).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledWith( - expect.any(Object), // dependencies - ['x', 'y', 'z'], // unique types of the fetched objects - ['bulk_get', 'share_to_space'], // actions - [spaceC, spaceA, spaceB, spaceD], // unique spaces of: the current space, spacesToAdd, spacesToRemove, and spaces that the fetched objects exist in (excludes '*') - { requireFullAuthorization: false } - ); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(5); - expectAuditEvent(AUDIT_ACTION, 'unknown', obj1); - expectAuditEvent(AUDIT_ACTION, 'unknown', obj2); - expectAuditEvent(AUDIT_ACTION, 'unknown', obj3); - expectAuditEvent(AUDIT_ACTION, 'unknown', obj4); - expectAuditEvent(AUDIT_ACTION, 'unknown', obj5); - expect(clientOpts.baseClient.updateObjectsSpaces).toHaveBeenCalledTimes(1); - expect(clientOpts.baseClient.updateObjectsSpaces).toHaveBeenCalledWith( - bulkGetResults.map(({ namespaces: spaces, ...otherAttrs }) => ({ spaces, ...otherAttrs })), - spacesToAdd, - spacesToRemove, - options - ); - }); - - test(`checks privileges for the global resource when spacesToAdd includes '*'`, async () => { - const bulkGetResults = [{ ...obj1, namespaces: [spaceA], version: 'v1' }] as SavedObject[]; - clientOpts.baseClient.bulkGet.mockResolvedValue({ saved_objects: bulkGetResults }); - mockEnsureAuthorized.mockResolvedValue({ - status: 'fully_authorized', - typeActionMap: new Map().set('x', { - bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, - share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, - }), - }); - clientOpts.baseClient.updateObjectsSpaces.mockResolvedValue({ - objects: [ - // The object was removed from spaceA and added to '*' - { ...obj1, spaces: ['*'] }, - ] as SavedObjectsUpdateObjectsSpacesResponseObject[], - }); - - const objects = [obj1]; - const spacesToAdd = ['*']; - const spacesToRemove = [spaceA]; - const options = { namespace: spaceC }; // spaceC is the current space - await client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options); - - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledWith( - expect.any(Object), // dependencies - ['x'], // unique types of the fetched objects - ['bulk_get', 'share_to_space'], // actions - [spaceC, '*', spaceA], // unique spaces of: the current space, spacesToAdd, spacesToRemove, and spaces that the fetched objects exist in (excludes '*') - { requireFullAuthorization: false } - ); - }); - - test(`checks privileges for the global resource when spacesToRemove includes '*'`, async () => { - const bulkGetResults = [{ ...obj1, namespaces: ['*'], version: 'v1' }] as SavedObject[]; - clientOpts.baseClient.bulkGet.mockResolvedValue({ saved_objects: bulkGetResults }); - mockEnsureAuthorized.mockResolvedValue({ - status: 'fully_authorized', - typeActionMap: new Map().set('x', { - bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, - share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, - }), - }); - clientOpts.baseClient.updateObjectsSpaces.mockResolvedValue({ - objects: [ - // The object was removed from spaceA and added to '*' - { ...obj1, spaces: ['*'] }, - ] as SavedObjectsUpdateObjectsSpacesResponseObject[], - }); - - const objects = [obj1]; - const spacesToAdd = [spaceA]; - const spacesToRemove = ['*']; - const options = { namespace: spaceC }; // spaceC is the current space - await client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options); - - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledWith( - expect.any(Object), // dependencies - ['x'], // unique types of the fetched objects - ['bulk_get', 'share_to_space'], // actions - [spaceC, spaceA, '*'], // unique spaces of: the current space, spacesToAdd, spacesToRemove, and spaces that the fetched objects exist in (excludes '*') - { requireFullAuthorization: false } - ); - }); -}); - -describe('other', () => { - test(`assigns errors from constructor to .errors`, () => { - expect(client.errors).toBe(clientOpts.errors); - }); - - test(`namespace redaction fails safe`, async () => { - const type = 'foo'; - const id = `${type}-id`; - const namespace = 'some-ns'; - const namespaces = ['some-other-namespace', '*', namespace]; - const returnValue = { namespaces, foo: 'bar' }; - clientOpts.baseClient.get.mockReturnValue(returnValue as any); - - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( - getMockCheckPrivilegesSuccess // privilege check for authorization - ); - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - // privilege check for namespace filtering - (_actions: string | string[], _namespaces?: string | string[]) => ({ - hasAllRequested: false, - username: USERNAME, - privileges: { - kibana: [ - // this is a contrived scenario as we *shouldn't* get both an unauthorized and authorized result for a given resource... - // however, in case we do, we should fail-safe (authorized + unauthorized = unauthorized) - { resource: 'some-other-namespace', privilege: 'login:', authorized: false }, - { resource: 'some-other-namespace', privilege: 'login:', authorized: true }, - ], - }, - }) - ); - - const result = await client.get(type, id, { namespace }); - // we will never redact the "All Spaces" ID - expect(result).toEqual(expect.objectContaining({ namespaces: ['*', namespace, '?'] })); - - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith('login:', [ - 'some-other-namespace', - ]); - }); -}); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts deleted file mode 100644 index b8e253b7f3160..0000000000000 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ /dev/null @@ -1,1201 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { - SavedObjectReferenceWithContext, - SavedObjectsBaseOptions, - SavedObjectsBulkCreateObject, - SavedObjectsBulkDeleteObject, - SavedObjectsBulkDeleteOptions, - SavedObjectsBulkDeleteResponse, - SavedObjectsBulkGetObject, - SavedObjectsBulkResolveObject, - SavedObjectsBulkUpdateObject, - SavedObjectsCheckConflictsObject, - SavedObjectsClientContract, - SavedObjectsClosePointInTimeOptions, - SavedObjectsCollectMultiNamespaceReferencesObject, - SavedObjectsCollectMultiNamespaceReferencesOptions, - SavedObjectsCollectMultiNamespaceReferencesResponse, - SavedObjectsCreateOptions, - SavedObjectsCreatePointInTimeFinderDependencies, - SavedObjectsCreatePointInTimeFinderOptions, - SavedObjectsFindOptions, - SavedObjectsOpenPointInTimeOptions, - SavedObjectsRemoveReferencesToOptions, - SavedObjectsUpdateObjectsSpacesObject, - SavedObjectsUpdateObjectsSpacesOptions, - SavedObjectsUpdateOptions, -} from '@kbn/core/server'; -import { SavedObjectsErrorHelpers, SavedObjectsUtils } from '@kbn/core/server'; - -import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; -import type { AuditLogger } from '../audit'; -import { SavedObjectAction, savedObjectEvent } from '../audit'; -import type { Actions, CheckSavedObjectsPrivileges } from '../authorization'; -import type { CheckPrivilegesResponse } from '../authorization/types'; -import type { SpacesService } from '../plugin'; -import type { - EnsureAuthorizedDependencies, - EnsureAuthorizedOptions, - EnsureAuthorizedResult, -} from './ensure_authorized'; -import { - ensureAuthorized, - getEnsureAuthorizedActionResult, - isAuthorizedForObjectInAllSpaces, -} from './ensure_authorized'; - -interface SecureSavedObjectsClientWrapperOptions { - actions: Actions; - auditLogger: AuditLogger; - baseClient: SavedObjectsClientContract; - errors: typeof SavedObjectsErrorHelpers; - checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; - getSpacesService(): SpacesService | undefined; -} - -interface SavedObjectNamespaces { - namespaces?: string[]; -} - -interface SavedObjectsNamespaces { - saved_objects: SavedObjectNamespaces[]; -} - -interface LegacyEnsureAuthorizedOptions { - args?: Record; - auditAction?: string; - requireFullAuthorization?: boolean; -} - -interface LegacyEnsureAuthorizedResult { - status: 'fully_authorized' | 'partially_authorized' | 'unauthorized'; - typeMap: Map; -} - -interface LegacyEnsureAuthorizedTypeResult { - authorizedSpaces: string[]; - isGloballyAuthorized?: boolean; -} - -export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { - private readonly actions: Actions; - private readonly auditLogger: AuditLogger; - private readonly baseClient: SavedObjectsClientContract; - private readonly checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; - private getSpacesService: () => SpacesService | undefined; - public readonly errors: typeof SavedObjectsErrorHelpers; - - constructor({ - actions, - auditLogger, - baseClient, - checkSavedObjectsPrivilegesAsCurrentUser, - errors, - getSpacesService, - }: SecureSavedObjectsClientWrapperOptions) { - this.errors = errors; - this.actions = actions; - this.auditLogger = auditLogger; - this.baseClient = baseClient; - this.checkSavedObjectsPrivilegesAsCurrentUser = checkSavedObjectsPrivilegesAsCurrentUser; - this.getSpacesService = getSpacesService; - } - - public async create( - type: string, - attributes: T = {} as T, - options: SavedObjectsCreateOptions = {} - ) { - const optionsWithId = { ...options, id: options.id ?? SavedObjectsUtils.generateId() }; - const namespaces = [optionsWithId.namespace, ...(optionsWithId.initialNamespaces || [])]; - try { - const args = { type, attributes, options: optionsWithId }; - await this.legacyEnsureAuthorized(type, 'create', namespaces, { args }); - } catch (error) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.CREATE, - savedObject: { type, id: optionsWithId.id }, - error, - }) - ); - throw error; - } - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.CREATE, - outcome: 'unknown', - savedObject: { type, id: optionsWithId.id }, - }) - ); - - const savedObject = await this.baseClient.create(type, attributes, optionsWithId); - return await this.redactSavedObjectNamespaces(savedObject, namespaces); - } - - public async checkConflicts( - objects: SavedObjectsCheckConflictsObject[] = [], - options: SavedObjectsBaseOptions = {} - ) { - const args = { objects, options }; - const types = this.getUniqueObjectTypes(objects); - await this.legacyEnsureAuthorized(types, 'bulk_create', options.namespace, { - args, - auditAction: 'checkConflicts', - }); - - const response = await this.baseClient.checkConflicts(objects, options); - return response; - } - - public async bulkCreate( - objects: Array>, - options: SavedObjectsBaseOptions = {} - ) { - const objectsWithId = objects.map((obj) => ({ - ...obj, - id: obj.id ?? SavedObjectsUtils.generateId(), - })); - const namespaces = objectsWithId.reduce( - (acc, { initialNamespaces = [] }) => acc.concat(initialNamespaces), - [options.namespace] - ); - try { - const args = { objects: objectsWithId, options }; - await this.legacyEnsureAuthorized( - this.getUniqueObjectTypes(objectsWithId), - 'bulk_create', - namespaces, - { - args, - } - ); - } catch (error) { - objectsWithId.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.CREATE, - savedObject: { type, id }, - error, - }) - ) - ); - throw error; - } - objectsWithId.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.CREATE, - outcome: 'unknown', - savedObject: { type, id }, - }) - ) - ); - - const response = await this.baseClient.bulkCreate(objectsWithId, options); - return await this.redactSavedObjectsNamespaces(response, namespaces); - } - - public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - try { - const args = { type, id, options }; - await this.legacyEnsureAuthorized(type, 'delete', options.namespace, { args }); - } catch (error) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.DELETE, - savedObject: { type, id }, - error, - }) - ); - throw error; - } - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.DELETE, - outcome: 'unknown', - savedObject: { type, id }, - }) - ); - - return await this.baseClient.delete(type, id, options); - } - - public async bulkDelete( - objects: SavedObjectsBulkDeleteObject[], - options: SavedObjectsBulkDeleteOptions - ): Promise { - try { - const args = { objects, options }; - await this.legacyEnsureAuthorized( - this.getUniqueObjectTypes(objects), - 'bulk_delete', - options?.namespace, - { - args, - } - ); - } catch (error) { - objects.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.DELETE, - savedObject: { type, id }, - error, - }) - ) - ); - throw error; - } - const response = await this.baseClient.bulkDelete(objects, options); - response?.statuses.forEach(({ id, type, success, error }) => { - const auditEventOutcome = success === true ? 'success' : 'failure'; - const auditEventOutcomeError = error ? (error as unknown as Error) : undefined; - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.DELETE, - savedObject: { type, id }, - outcome: auditEventOutcome, - error: auditEventOutcomeError, - }) - ); - }); - return response; - } - - public async find(options: SavedObjectsFindOptions) { - if ( - this.getSpacesService() == null && - Array.isArray(options.namespaces) && - options.namespaces.length > 0 - ) { - throw this.errors.createBadRequestError( - `_find across namespaces is not permitted when the Spaces plugin is disabled.` - ); - } - - const args = { options }; - const { status, typeMap } = await this.legacyEnsureAuthorized( - options.type, - 'find', - options.namespaces, - { args, requireFullAuthorization: false } - ); - - if (status === 'unauthorized') { - // return empty response - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.FIND, - error: new Error(status), - }) - ); - return SavedObjectsUtils.createEmptyFindResponse(options); - } - - const typeToNamespacesMap = Array.from(typeMap).reduce>( - (acc, [type, { authorizedSpaces, isGloballyAuthorized }]) => - isGloballyAuthorized ? acc.set(type, options.namespaces) : acc.set(type, authorizedSpaces), - new Map() - ); - - const response = await this.baseClient.find({ - ...options, - typeToNamespacesMap: undefined, // if the user is fully authorized, use `undefined` as the typeToNamespacesMap to prevent privilege escalation - ...(status === 'partially_authorized' && { typeToNamespacesMap, type: '', namespaces: [] }), // the repository requires that `type` and `namespaces` must be empty if `typeToNamespacesMap` is defined - }); - - response.saved_objects.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.FIND, - savedObject: { type, id }, - }) - ) - ); - - return await this.redactSavedObjectsNamespaces(response, options.namespaces ?? [undefined]); - } - - public async bulkGet( - objects: SavedObjectsBulkGetObject[] = [], - options: SavedObjectsBaseOptions = {} - ) { - try { - const namespaces = objects.reduce( - (acc, { namespaces: objNamespaces = [] }) => acc.concat(objNamespaces), - [options.namespace] - ); - const args = { objects, options }; - await this.legacyEnsureAuthorized( - this.getUniqueObjectTypes(objects), - 'bulk_get', - namespaces, - { - args, - } - ); - } catch (error) { - objects.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.GET, - savedObject: { type, id }, - error, - }) - ) - ); - throw error; - } - - const response = await this.baseClient.bulkGet(objects, options); - - response.saved_objects.forEach(({ error, type, id }) => { - if (!error) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.GET, - savedObject: { type, id }, - }) - ); - } - }); - - return await this.redactSavedObjectsNamespaces(response, [options.namespace]); - } - - public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - try { - const args = { type, id, options }; - await this.legacyEnsureAuthorized(type, 'get', options.namespace, { args }); - } catch (error) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.GET, - savedObject: { type, id }, - error, - }) - ); - throw error; - } - - const savedObject = await this.baseClient.get(type, id, options); - - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.GET, - savedObject: { type, id }, - }) - ); - - return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]); - } - - public async bulkResolve( - objects: SavedObjectsBulkResolveObject[], - options: SavedObjectsBaseOptions = {} - ) { - try { - const args = { objects, options }; - await this.legacyEnsureAuthorized( - this.getUniqueObjectTypes(objects), - 'bulk_get', - options.namespace, - { args, auditAction: 'bulk_resolve' } - ); - } catch (error) { - objects.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.RESOLVE, - savedObject: { type, id }, - error, - }) - ) - ); - throw error; - } - - const response = await this.baseClient.bulkResolve(objects, options); - - response.resolved_objects.forEach(({ saved_object: { error, type, id } }) => { - if (!error) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.RESOLVE, - savedObject: { type, id }, - }) - ); - } - }); - - // the generic redactSavedObjectsNamespaces function cannot be used here due to the nested structure of the - // resolved objects, so we handle redaction in a bespoke manner for bulkResolve - - if (this.getSpacesService() === undefined) { - return response; - } - - const previouslyAuthorizedSpaceIds = [ - this.getSpacesService()!.namespaceToSpaceId(options.namespace), - ]; - // all users can see the "all spaces" ID, and we don't need to recheck authorization for any namespaces that we just checked earlier - const namespaces = uniq( - response.resolved_objects.flatMap((resolved) => resolved.saved_object.namespaces || []) - ).filter((x) => x !== ALL_SPACES_ID && !previouslyAuthorizedSpaceIds.includes(x)); - - const privilegeMap = await this.getNamespacesPrivilegeMap( - namespaces, - previouslyAuthorizedSpaceIds - ); - - return { - ...response, - resolved_objects: response.resolved_objects.map((resolved) => ({ - ...resolved, - saved_object: { - ...resolved.saved_object, - namespaces: - resolved.saved_object.namespaces && - this.redactAndSortNamespaces(resolved.saved_object.namespaces, privilegeMap), - }, - })), - }; - } - - public async resolve( - type: string, - id: string, - options: SavedObjectsBaseOptions = {} - ) { - try { - const args = { type, id, options }; - await this.legacyEnsureAuthorized(type, 'get', options.namespace, { - args, - auditAction: 'resolve', - }); - } catch (error) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.RESOLVE, - savedObject: { type, id }, - error, - }) - ); - throw error; - } - - const resolveResult = await this.baseClient.resolve(type, id, options); - - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.RESOLVE, - savedObject: { type, id: resolveResult.saved_object.id }, - }) - ); - - return { - ...resolveResult, - saved_object: await this.redactSavedObjectNamespaces(resolveResult.saved_object, [ - options.namespace, - ]), - }; - } - - public async update( - type: string, - id: string, - attributes: Partial, - options: SavedObjectsUpdateOptions = {} - ) { - try { - const args = { type, id, attributes, options }; - await this.legacyEnsureAuthorized(type, 'update', options.namespace, { args }); - } catch (error) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.UPDATE, - savedObject: { type, id }, - error, - }) - ); - throw error; - } - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.UPDATE, - outcome: 'unknown', - savedObject: { type, id }, - }) - ); - - const savedObject = await this.baseClient.update(type, id, attributes, options); - return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]); - } - - public async bulkUpdate( - objects: Array> = [], - options: SavedObjectsBaseOptions = {} - ) { - const objectNamespaces = objects - // The repository treats an `undefined` object namespace is treated as the absence of a namespace, falling back to options.namespace; - // in this case, filter it out here so we don't accidentally check for privileges in the Default space when we shouldn't be doing so. - .filter(({ namespace }) => namespace !== undefined) - .map(({ namespace }) => namespace!); - const namespaces = [options?.namespace, ...objectNamespaces]; - try { - const args = { objects, options }; - await this.legacyEnsureAuthorized( - this.getUniqueObjectTypes(objects), - 'bulk_update', - namespaces, - { - args, - } - ); - } catch (error) { - objects.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.UPDATE, - savedObject: { type, id }, - error, - }) - ) - ); - throw error; - } - objects.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.UPDATE, - outcome: 'unknown', - savedObject: { type, id }, - }) - ) - ); - - const response = await this.baseClient.bulkUpdate(objects, options); - return await this.redactSavedObjectsNamespaces(response, namespaces); - } - - public async removeReferencesTo( - type: string, - id: string, - options: SavedObjectsRemoveReferencesToOptions = {} - ) { - try { - const args = { type, id, options }; - await this.legacyEnsureAuthorized(type, 'delete', options.namespace, { - args, - auditAction: 'removeReferences', - }); - } catch (error) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.REMOVE_REFERENCES, - savedObject: { type, id }, - error, - }) - ); - throw error; - } - - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.REMOVE_REFERENCES, - savedObject: { type, id }, - outcome: 'unknown', - }) - ); - - return await this.baseClient.removeReferencesTo(type, id, options); - } - - public async openPointInTimeForType( - type: string | string[], - options: SavedObjectsOpenPointInTimeOptions - ) { - const args = { type, options }; - const { status, typeMap } = await this.legacyEnsureAuthorized( - type, - 'open_point_in_time', - options?.namespaces, - { - args, - // Partial authorization is acceptable in this case because this method is only designed - // to be used with `find`, which already allows for partial authorization. - requireFullAuthorization: false, - } - ); - - if (status === 'unauthorized') { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.OPEN_POINT_IN_TIME, - error: new Error(status), - }) - ); - throw SavedObjectsErrorHelpers.decorateForbiddenError(new Error(status)); - } - - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.OPEN_POINT_IN_TIME, - outcome: 'unknown', - }) - ); - - const allowedTypes = [...typeMap.keys()]; // only allow the user to open a PIT against indices for type(s) they are authorized to access - return await this.baseClient.openPointInTimeForType(allowedTypes, options); - } - - public async closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions) { - // We are intentionally omitting a call to `ensureAuthorized` here, because `closePointInTime` - // doesn't take in `types`, which are required to perform authorization. As there is no way - // to know what index/indices a PIT was created against, we have no practical means of - // authorizing users. We've decided we are okay with this because: - // (a) Elasticsearch only requires `read` privileges on an index in order to open/close - // a PIT against it, and; - // (b) By the time a user is accessing this service, they are already authenticated - // to Kibana, which is our closest equivalent to Elasticsearch's `read`. - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.CLOSE_POINT_IN_TIME, - outcome: 'unknown', - }) - ); - - return await this.baseClient.closePointInTime(id, options); - } - - public createPointInTimeFinder( - findOptions: SavedObjectsCreatePointInTimeFinderOptions, - dependencies?: SavedObjectsCreatePointInTimeFinderDependencies - ) { - // We don't need to perform an authorization check here or add an audit log, because - // `createPointInTimeFinder` is simply a helper that calls `find`, `openPointInTimeForType`, - // and `closePointInTime` internally, so authz checks and audit logs will already be applied. - return this.baseClient.createPointInTimeFinder(findOptions, { - client: this, - // Include dependencies last so that subsequent SO client wrappers have their settings applied. - ...dependencies, - }); - } - - public async collectMultiNamespaceReferences( - objects: SavedObjectsCollectMultiNamespaceReferencesObject[], - options: SavedObjectsCollectMultiNamespaceReferencesOptions = {} - ): Promise { - const currentSpaceId = SavedObjectsUtils.namespaceIdToString(options.namespace); // We need this whether the Spaces plugin is enabled or not. - - // We don't know the space(s) that each object exists in, so we'll collect the objects and references first, then check authorization. - const response = await this.baseClient.collectMultiNamespaceReferences(objects, options); - const uniqueTypes = this.getUniqueObjectTypes(response.objects); - const uniqueSpaces = this.getUniqueSpaces( - currentSpaceId, - ...response.objects.flatMap( - ({ spaces, spacesWithMatchingAliases = [], spacesWithMatchingOrigins = [] }) => [ - ...spaces, - ...spacesWithMatchingAliases, - ...spacesWithMatchingOrigins, - ] - ) - ); - - const { typeActionMap } = await this.ensureAuthorized( - uniqueTypes, - options.purpose === 'updateObjectsSpaces' ? ['bulk_get', 'share_to_space'] : ['bulk_get'], - uniqueSpaces, - { requireFullAuthorization: false } - ); - - // The user must be authorized to access every requested object in the current space. - // Note: non-multi-namespace object types will have an empty spaces array. - const authAction = options.purpose === 'updateObjectsSpaces' ? 'share_to_space' : 'bulk_get'; - try { - this.ensureAuthorizedInAllSpaces(objects, authAction, typeActionMap, [currentSpaceId]); - } catch (error) { - objects.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.COLLECT_MULTINAMESPACE_REFERENCES, - savedObject: { type, id }, - error, - }) - ) - ); - throw error; - } - - // The user is authorized to access all of the requested objects in the space(s) that they exist in. - // Now: 1. omit any result objects that the user has no access to, 2. for the rest, redact any space(s) that the user is not authorized - // for, and 3. create audit records for any objects that will be returned to the user. - const requestedObjectsSet = objects.reduce( - (acc, { type, id }) => acc.add(`${type}:${id}`), - new Set() - ); - const retrievedObjectsSet = response.objects.reduce( - (acc, { type, id }) => acc.add(`${type}:${id}`), - new Set() - ); - const traversedObjects = new Set(); - const filteredObjectsMap = new Map(); - const getIsAuthorizedForInboundReference = (inbound: { type: string; id: string }) => { - const found = filteredObjectsMap.get(`${inbound.type}:${inbound.id}`); - return found && !found.isMissing; // If true, this object can be linked back to one of the requested objects - }; - let objectsToProcess = [...response.objects]; - while (objectsToProcess.length > 0) { - const obj = objectsToProcess.shift()!; - const { type, id, spaces, inboundReferences } = obj; - const objKey = `${type}:${id}`; - traversedObjects.add(objKey); - // Is the user authorized to access this object in all required space(s)? - const isAuthorizedForObject = isAuthorizedForObjectInAllSpaces( - type, - authAction, - typeActionMap, - [currentSpaceId] - ); - // Redact the inbound references so we don't leak any info about other objects that the user is not authorized to access - const redactedInboundReferences = inboundReferences.filter((inbound) => { - if (inbound.type === type && inbound.id === id) { - // circular reference, don't redact it - return true; - } - return getIsAuthorizedForInboundReference(inbound); - }); - // If the user is not authorized to access at least one inbound reference of this object, then we should omit this object. - const isAuthorizedForGraph = - requestedObjectsSet.has(objKey) || // If true, this is one of the requested objects, and we checked authorization above - redactedInboundReferences.some(getIsAuthorizedForInboundReference); - - if (isAuthorizedForObject && isAuthorizedForGraph) { - if (spaces.length) { - // Don't generate audit records for "empty results" with zero spaces (requested object was a non-multi-namespace type or hidden type) - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.COLLECT_MULTINAMESPACE_REFERENCES, - savedObject: { type, id }, - }) - ); - } - filteredObjectsMap.set(objKey, obj); - } else if (!isAuthorizedForObject && isAuthorizedForGraph) { - filteredObjectsMap.set(objKey, { ...obj, spaces: [], isMissing: true }); - } else if (isAuthorizedForObject && !isAuthorizedForGraph) { - const hasUntraversedInboundReferences = inboundReferences.some( - (ref) => - !traversedObjects.has(`${ref.type}:${ref.id}`) && - retrievedObjectsSet.has(`${ref.type}:${ref.id}`) - ); - - if (hasUntraversedInboundReferences) { - // this object has inbound reference(s) that we haven't traversed yet; bump it to the back of the list - objectsToProcess = [...objectsToProcess, obj]; - } else { - // There should never be a missing inbound reference. - // If there is, then something has gone terribly wrong. - const missingInboundReference = inboundReferences.find( - (ref) => - !traversedObjects.has(`${ref.type}:${ref.id}`) && - !retrievedObjectsSet.has(`${ref.type}:${ref.id}`) - ); - - if (missingInboundReference) { - throw new Error( - `Unexpected inbound reference to "${missingInboundReference.type}:${missingInboundReference.id}"` - ); - } - } - } - } - - const filteredAndRedactedObjects = [...filteredObjectsMap.values()].map((obj) => { - const { - type, - id, - spaces, - spacesWithMatchingAliases, - spacesWithMatchingOrigins, - inboundReferences, - } = obj; - // Redact the inbound references so we don't leak any info about other objects that the user is not authorized to access - const redactedInboundReferences = inboundReferences.filter((inbound) => { - if (inbound.type === type && inbound.id === id) { - // circular reference, don't redact it - return true; - } - return getIsAuthorizedForInboundReference(inbound); - }); - const redactedSpaces = getRedactedSpaces(type, 'bulk_get', typeActionMap, spaces); - const redactedSpacesWithMatchingAliases = - spacesWithMatchingAliases && - getRedactedSpaces(type, 'bulk_get', typeActionMap, spacesWithMatchingAliases); - const redactedSpacesWithMatchingOrigins = - spacesWithMatchingOrigins && - getRedactedSpaces(type, 'bulk_get', typeActionMap, spacesWithMatchingOrigins); - return { - ...obj, - spaces: redactedSpaces, - ...(redactedSpacesWithMatchingAliases && { - spacesWithMatchingAliases: redactedSpacesWithMatchingAliases, - }), - ...(redactedSpacesWithMatchingOrigins && { - spacesWithMatchingOrigins: redactedSpacesWithMatchingOrigins, - }), - inboundReferences: redactedInboundReferences, - }; - }); - - return { - objects: filteredAndRedactedObjects, - }; - } - - public async updateObjectsSpaces( - objects: SavedObjectsUpdateObjectsSpacesObject[], - spacesToAdd: string[], - spacesToRemove: string[], - options: SavedObjectsUpdateObjectsSpacesOptions = {} - ) { - const { namespace } = options; - const currentSpaceId = SavedObjectsUtils.namespaceIdToString(namespace); // We need this whether the Spaces plugin is enabled or not. - - const allSpacesSet = new Set([currentSpaceId, ...spacesToAdd, ...spacesToRemove]); - const bulkGetResponse = await this.baseClient.bulkGet(objects, { namespace }); - const objectsToUpdate = objects.map(({ type, id }, i) => { - const { namespaces: spaces = [], version } = bulkGetResponse.saved_objects[i]; - // If 'namespaces' is undefined, the object was not found (or it is namespace-agnostic). - // Either way, we will pass in an empty 'spaces' array to the base client, which will cause it to skip this object. - for (const space of spaces) { - if (space !== ALL_SPACES_ID) { - // If this is a specific space, add it to the spaces we'll check privileges for (don't accidentally check for global privileges) - allSpacesSet.add(space); - } - } - return { type, id, spaces, version }; - }); - - const uniqueTypes = this.getUniqueObjectTypes(objects); - const { typeActionMap } = await this.ensureAuthorized( - uniqueTypes, - ['bulk_get', 'share_to_space'], - Array.from(allSpacesSet), - { requireFullAuthorization: false } - ); - - const addToSpaces = spacesToAdd.length ? spacesToAdd : undefined; - const deleteFromSpaces = spacesToRemove.length ? spacesToRemove : undefined; - try { - // The user must be authorized to share every requested object in each of: the current space, spacesToAdd, and spacesToRemove. - const spaces = this.getUniqueSpaces(currentSpaceId, ...spacesToAdd, ...spacesToRemove); - this.ensureAuthorizedInAllSpaces(objects, 'share_to_space', typeActionMap, spaces); - } catch (error) { - objects.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.UPDATE_OBJECTS_SPACES, - savedObject: { type, id }, - addToSpaces, - deleteFromSpaces, - error, - }) - ) - ); - throw error; - } - for (const { type, id } of objectsToUpdate) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.UPDATE_OBJECTS_SPACES, - outcome: 'unknown', - savedObject: { type, id }, - addToSpaces, - deleteFromSpaces, - }) - ); - } - - const response = await this.baseClient.updateObjectsSpaces( - objectsToUpdate, - spacesToAdd, - spacesToRemove, - { namespace } - ); - // Now that we have updated the objects' spaces, redact any spaces that the user is not authorized to see from the response. - const redactedObjects = response.objects.map((obj) => { - const { type, spaces } = obj; - const redactedSpaces = getRedactedSpaces(type, 'bulk_get', typeActionMap, spaces); - return { ...obj, spaces: redactedSpaces }; - }); - - return { objects: redactedObjects }; - } - - private async checkPrivileges( - actions: string | string[], - namespaceOrNamespaces?: string | Array - ) { - try { - return await this.checkSavedObjectsPrivilegesAsCurrentUser(actions, namespaceOrNamespaces); - } catch (error) { - throw this.errors.decorateGeneralError(error, error.body && error.body.reason); - } - } - - private async legacyEnsureAuthorized( - typeOrTypes: string | string[], - action: string, - namespaceOrNamespaces: undefined | string | Array, - options: LegacyEnsureAuthorizedOptions = {} - ): Promise { - const { requireFullAuthorization = true } = options; - const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; - const actionsToTypesMap = new Map( - types.map((type) => [this.actions.savedObject.get(type, action), type]) - ); - const actions = Array.from(actionsToTypesMap.keys()); - const result = await this.checkPrivileges(actions, namespaceOrNamespaces); - - const { hasAllRequested, privileges } = result; - - const missingPrivileges = this.getMissingPrivileges(privileges); - const typeMap = privileges.kibana.reduce>( - (acc, { resource, privilege, authorized }) => { - if (!authorized) { - return acc; - } - const type = actionsToTypesMap.get(privilege)!; // always defined - const value = acc.get(type) ?? { authorizedSpaces: [] }; - if (resource === undefined) { - return acc.set(type, { ...value, isGloballyAuthorized: true }); - } - const authorizedSpaces = value.authorizedSpaces.concat(resource); - return acc.set(type, { ...value, authorizedSpaces }); - }, - new Map() - ); - - if (hasAllRequested) { - return { typeMap, status: 'fully_authorized' }; - } else if (!requireFullAuthorization) { - const isPartiallyAuthorized = privileges.kibana.some(({ authorized }) => authorized); - if (isPartiallyAuthorized) { - return { typeMap, status: 'partially_authorized' }; - } else { - return { typeMap, status: 'unauthorized' }; - } - } else { - const targetTypes = uniq( - missingPrivileges.map(({ privilege }) => actionsToTypesMap.get(privilege)).sort() - ).join(','); - const msg = `Unable to ${action} ${targetTypes}`; - throw this.errors.decorateForbiddenError(new Error(msg)); - } - } - - /** Unlike `legacyEnsureAuthorized`, this accepts multiple actions, and it does not utilize legacy audit logging */ - private async ensureAuthorized( - types: string[], - actions: T[], - namespaces: string[], - options?: EnsureAuthorizedOptions - ) { - const ensureAuthorizedDependencies: EnsureAuthorizedDependencies = { - actions: this.actions, - errors: this.errors, - checkSavedObjectsPrivilegesAsCurrentUser: this.checkSavedObjectsPrivilegesAsCurrentUser, - }; - return ensureAuthorized(ensureAuthorizedDependencies, types, actions, namespaces, options); - } - - /** - * If `ensureAuthorized` was called with `requireFullAuthorization: false`, this can be used with the result to ensure that a given - * array of objects are authorized in the required space(s). - */ - private ensureAuthorizedInAllSpaces( - objects: Array<{ type: string }>, - action: T, - typeActionMap: EnsureAuthorizedResult['typeActionMap'], - spaces: string[] - ) { - const uniqueTypes = uniq(objects.map(({ type }) => type)); - const unauthorizedTypes = new Set(); - for (const type of uniqueTypes) { - if (!isAuthorizedForObjectInAllSpaces(type, action, typeActionMap, spaces)) { - unauthorizedTypes.add(type); - } - } - if (unauthorizedTypes.size > 0) { - const targetTypes = Array.from(unauthorizedTypes).sort().join(','); - const msg = `Unable to ${action} ${targetTypes}`; - throw this.errors.decorateForbiddenError(new Error(msg)); - } - } - - private getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { - return privileges.kibana - .filter(({ authorized }) => !authorized) - .map(({ resource, privilege }) => ({ spaceId: resource, privilege })); - } - - private getUniqueObjectTypes(objects: Array<{ type: string }>) { - return uniq(objects.map((o) => o.type)); - } - - /** - * Given a list of spaces, returns a unique array of spaces. - * Excludes `'*'`, which is an identifier for All Spaces but is not an actual space. - */ - private getUniqueSpaces(...spaces: string[]) { - const set = new Set(spaces); - set.delete(ALL_SPACES_ID); - return Array.from(set); - } - - private async getNamespacesPrivilegeMap( - namespaces: string[], - previouslyAuthorizedSpaceIds: string[] - ) { - const namespacesToCheck = namespaces.filter( - (namespace) => !previouslyAuthorizedSpaceIds.includes(namespace) - ); - const initialPrivilegeMap = previouslyAuthorizedSpaceIds.reduce( - (acc, spaceId) => acc.set(spaceId, true), - new Map() - ); - if (namespacesToCheck.length === 0) { - return initialPrivilegeMap; - } - const action = this.actions.login; - const checkPrivilegesResult = await this.checkPrivileges(action, namespacesToCheck); - // check if the user can log into each namespace - const map = checkPrivilegesResult.privileges.kibana.reduce((acc, { resource, authorized }) => { - // there should never be a case where more than one privilege is returned for a given space - // if there is, fail-safe (authorized + unauthorized = unauthorized) - if (resource && (!authorized || !acc.has(resource))) { - acc.set(resource, authorized); - } - return acc; - }, initialPrivilegeMap); - return map; - } - - private redactAndSortNamespaces(spaceIds: string[], privilegeMap: Map) { - return spaceIds - .map((x) => (x === ALL_SPACES_ID || privilegeMap.get(x) ? x : UNKNOWN_SPACE)) - .sort(namespaceComparator); - } - - private async redactSavedObjectNamespaces( - savedObject: T, - previouslyAuthorizedNamespaces: Array - ): Promise { - if ( - this.getSpacesService() === undefined || - savedObject.namespaces == null || - savedObject.namespaces.length === 0 - ) { - return savedObject; - } - - const previouslyAuthorizedSpaceIds = previouslyAuthorizedNamespaces.map((x) => - this.getSpacesService()!.namespaceToSpaceId(x) - ); - // all users can see the "all spaces" ID, and we don't need to recheck authorization for any namespaces that we just checked earlier - const namespaces = savedObject.namespaces.filter( - (x) => x !== ALL_SPACES_ID && !previouslyAuthorizedSpaceIds.includes(x) - ); - - const privilegeMap = await this.getNamespacesPrivilegeMap( - namespaces, - previouslyAuthorizedSpaceIds - ); - - return { - ...savedObject, - namespaces: this.redactAndSortNamespaces(savedObject.namespaces, privilegeMap), - }; - } - - private async redactSavedObjectsNamespaces( - response: T, - previouslyAuthorizedNamespaces: Array - ): Promise { - // WARNING: the bulkResolve function has a bespoke implementation of this; any changes here should be applied there too. - - if (this.getSpacesService() === undefined) { - return response; - } - - const previouslyAuthorizedSpaceIds = previouslyAuthorizedNamespaces.map((x) => - this.getSpacesService()!.namespaceToSpaceId(x) - ); - const { saved_objects: savedObjects } = response; - // all users can see the "all spaces" ID, and we don't need to recheck authorization for any namespaces that we just checked earlier - const namespaces = uniq( - savedObjects.flatMap((savedObject) => savedObject.namespaces || []) - ).filter((x) => x !== ALL_SPACES_ID && !previouslyAuthorizedSpaceIds.includes(x)); - - const privilegeMap = await this.getNamespacesPrivilegeMap( - namespaces, - previouslyAuthorizedSpaceIds - ); - - return { - ...response, - saved_objects: savedObjects.map((savedObject) => ({ - ...savedObject, - namespaces: - savedObject.namespaces && - this.redactAndSortNamespaces(savedObject.namespaces, privilegeMap), - })), - }; - } -} - -/** - * Returns all unique elements of an array. - */ -function uniq(arr: T[]): T[] { - return Array.from(new Set(arr)); -} - -/** - * Utility function to sort potentially redacted namespaces. - * Sorts in a case-insensitive manner, and ensures that redacted namespaces ('?') always show up at the end of the array. - */ -function namespaceComparator(a: string, b: string) { - const A = a.toUpperCase(); - const B = b.toUpperCase(); - if (A === UNKNOWN_SPACE && B !== UNKNOWN_SPACE) { - return 1; - } else if (A !== UNKNOWN_SPACE && B === UNKNOWN_SPACE) { - return -1; - } - return A > B ? 1 : A < B ? -1 : 0; -} - -function getRedactedSpaces( - objectType: string, - action: T, - typeActionMap: EnsureAuthorizedResult['typeActionMap'], - spacesToRedact: string[] -) { - const actionResult = getEnsureAuthorizedActionResult(objectType, action, typeActionMap); - const { authorizedSpaces, isGloballyAuthorized } = actionResult; - const authorizedSpacesSet = new Set(authorizedSpaces); - return spacesToRedact - .map((x) => - isGloballyAuthorized || x === ALL_SPACES_ID || authorizedSpacesSet.has(x) ? x : UNKNOWN_SPACE - ) - .sort(namespaceComparator); -} diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.mocks.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.mocks.ts deleted file mode 100644 index 02bd9971f28b8..0000000000000 --- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.mocks.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { ensureAuthorized } from '../saved_objects'; - -export const mockEnsureAuthorized = jest.fn() as jest.MockedFunction; - -jest.mock('../saved_objects', () => { - return { - ...jest.requireActual('../saved_objects'), - ensureAuthorized: mockEnsureAuthorized, - }; -}); diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts index 2b65eff88d36f..caa45118e2879 100644 --- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { mockEnsureAuthorized } from './secure_spaces_client_wrapper.test.mocks'; - +import { savedObjectsExtensionsMock } from '@kbn/core-saved-objects-api-server-mocks'; +import type { ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server'; +import { AuditAction } from '@kbn/core-saved-objects-server'; import type { EcsEventOutcome, SavedObjectsFindResponse } from '@kbn/core/server'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { httpServerMock } from '@kbn/core/server/mocks'; @@ -15,7 +16,7 @@ import { spacesClientMock } from '@kbn/spaces-plugin/server/mocks'; import { deepFreeze } from '@kbn/std'; import type { AuditEvent, AuditLogger } from '../audit'; -import { SavedObjectAction, SpaceAuditAction } from '../audit'; +import { SpaceAuditAction } from '../audit'; import { auditLoggerMock } from '../audit/mocks'; import type { AuthorizationServiceSetup, @@ -104,12 +105,17 @@ const setup = ({ securityEnabled = false }: Opts = {}) => { // other errors exist but are not needed for these test cases } as unknown as jest.Mocked; + const securityExtension = securityEnabled + ? (savedObjectsExtensionsMock.create() + .securityExtension as jest.Mocked) + : undefined; const wrapper = new SecureSpacesClientWrapper( baseClient, request, authorization, auditLogger, - errors + errors, + securityExtension ); return { authorization, @@ -118,6 +124,7 @@ const setup = ({ securityEnabled = false }: Opts = {}) => { baseClient, auditLogger, forbiddenError, + securityExtension, }; }; @@ -150,10 +157,6 @@ const expectAuditEvent = ( ); }; -beforeEach(() => { - mockEnsureAuthorized.mockReset(); -}); - describe('SecureSpacesClientWrapper', () => { describe('#getAll', () => { const savedObjects = [ @@ -665,9 +668,9 @@ describe('SecureSpacesClientWrapper', () => { it('deletes the space with all saved objects when authorized', async () => { const username = 'some_user'; - const { wrapper, baseClient, authorization, auditLogger, request } = setup({ - securityEnabled: true, - }); + const { wrapper, baseClient, authorization, auditLogger, request, securityExtension } = setup( + { securityEnabled: true } + ); const checkPrivileges = jest.fn().mockResolvedValue({ username, @@ -694,13 +697,17 @@ describe('SecureSpacesClientWrapper', () => { type: 'space', id: space.id, }); - expectAuditEvent(auditLogger, SavedObjectAction.DELETE, 'unknown', { - type: 'dashboard', - id: '2', + expect(securityExtension!.addAuditEvent).toHaveBeenCalledTimes(2); + expect(securityExtension!.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.DELETE, + outcome: 'unknown', + savedObject: { type: 'dashboard', id: '2' }, }); - expectAuditEvent(auditLogger, SavedObjectAction.UPDATE_OBJECTS_SPACES, 'unknown', { - type: 'dashboard', - id: '3', + expect(securityExtension!.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.UPDATE_OBJECTS_SPACES, + outcome: 'unknown', + savedObject: { type: 'dashboard', id: '3' }, + deleteFromSpaces: [space.id], }); }); }); @@ -710,39 +717,41 @@ describe('SecureSpacesClientWrapper', () => { const alias2 = { targetSpace: 'space-2', targetType: 'type-2', sourceId: 'id' }; function expectAuditEvents( - auditLogger: AuditLogger, + securityExtension: jest.Mocked, aliases: LegacyUrlAliasTarget[], - action: EcsEventOutcome + { error }: { error: boolean } ) { aliases.forEach((alias) => { - expectAuditEvent(auditLogger, SavedObjectAction.UPDATE, action, { - type: LEGACY_URL_ALIAS_TYPE, - id: getAliasId(alias), + expect(securityExtension!.addAuditEvent).toHaveBeenCalledWith({ + action: AuditAction.UPDATE, + savedObject: { type: LEGACY_URL_ALIAS_TYPE, id: getAliasId(alias) }, + ...(error ? { error: expect.anything() } : { outcome: 'unknown' }), }); }); } - function expectAuthorizationCheck(targetTypes: string[], targetSpaces: string[]) { - expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); - expect(mockEnsureAuthorized).toHaveBeenCalledWith( - expect.any(Object), // dependencies - targetTypes, // unique types of the alias targets - ['bulk_update'], // actions - targetSpaces, // unique spaces of the alias targets - { requireFullAuthorization: false } - ); + function expectAuthorizationCheck( + securityExtension: jest.Mocked, + targetTypes: string[], + targetSpaces: string[] + ) { + expect(securityExtension!.checkAuthorization).toHaveBeenCalledTimes(1); + expect(securityExtension!.checkAuthorization).toHaveBeenCalledWith({ + types: new Set(targetTypes), // unique types of the alias targets + spaces: new Set(targetSpaces), // unique spaces of the alias targets + actions: new Set(['bulk_update']), + }); } describe('when security is not enabled', () => { const securityEnabled = false; it('delegates to base client without checking authorization', async () => { - const { wrapper, baseClient, auditLogger } = setup({ securityEnabled }); + const { wrapper, baseClient, securityExtension } = setup({ securityEnabled }); const aliases = [alias1]; await wrapper.disableLegacyUrlAliases(aliases); - expect(mockEnsureAuthorized).not.toHaveBeenCalled(); - expectAuditEvents(auditLogger, aliases, 'unknown'); + expect(securityExtension).toBeUndefined(); expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledTimes(1); expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledWith(aliases); }); @@ -751,49 +760,41 @@ describe('SecureSpacesClientWrapper', () => { describe('when security is enabled', () => { const securityEnabled = true; - it('re-throws the error if the authorization check fails', async () => { - const error = new Error('Oh no!'); - mockEnsureAuthorized.mockRejectedValue(error); - const { wrapper, baseClient, auditLogger } = setup({ securityEnabled }); - const aliases = [alias1, alias2]; - await expect(() => wrapper.disableLegacyUrlAliases(aliases)).rejects.toThrow(error); - - expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']); - expectAuditEvents(auditLogger, aliases, 'failure'); - expect(baseClient.disableLegacyUrlAliases).not.toHaveBeenCalled(); - }); - it('throws a forbidden error when unauthorized', async () => { - mockEnsureAuthorized.mockResolvedValue({ - status: 'partially_authorized', - typeActionMap: new Map() - .set('type-1', { bulk_update: { authorizedSpaces: ['space-1'] } }) - .set('type-2', { bulk_update: { authorizedSpaces: ['space-1'] } }), // the user is not authorized to bulkUpdate type-2 in space-2, so this will throw a forbidden error + const { wrapper, baseClient, forbiddenError, securityExtension } = setup({ + securityEnabled, + }); + securityExtension!.checkAuthorization.mockResolvedValue({ + // These values don't actually matter, the call to enforceAuthorization matters + status: 'unauthorized', + typeMap: new Map(), + }); + securityExtension!.enforceAuthorization.mockImplementation(() => { + throw new Error('Oh no!'); }); - const { wrapper, baseClient, auditLogger, forbiddenError } = setup({ securityEnabled }); const aliases = [alias1, alias2]; await expect(() => wrapper.disableLegacyUrlAliases(aliases)).rejects.toThrow( forbiddenError ); - expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']); - expectAuditEvents(auditLogger, aliases, 'failure'); + expectAuthorizationCheck(securityExtension!, ['type-1', 'type-2'], ['space-1', 'space-2']); + expectAuditEvents(securityExtension!, aliases, { error: true }); expect(baseClient.disableLegacyUrlAliases).not.toHaveBeenCalled(); }); it('updates the legacy URL aliases when authorized', async () => { - mockEnsureAuthorized.mockResolvedValue({ - status: 'partially_authorized', - typeActionMap: new Map() - .set('type-1', { bulk_update: { authorizedSpaces: ['space-1'] } }) - .set('type-2', { bulk_update: { authorizedSpaces: ['space-2'] } }), + const { wrapper, baseClient, securityExtension } = setup({ securityEnabled }); + securityExtension!.checkAuthorization.mockResolvedValue({ + // These values don't actually matter, the call to enforceAuthorization matters + status: 'fully_authorized', + typeMap: new Map(), }); - const { wrapper, baseClient, auditLogger } = setup({ securityEnabled }); + // enforceAuthorization does *not* throw an error by default const aliases = [alias1, alias2]; await wrapper.disableLegacyUrlAliases(aliases); - expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']); - expectAuditEvents(auditLogger, aliases, 'unknown'); + expectAuthorizationCheck(securityExtension!, ['type-1', 'type-2'], ['space-1', 'space-2']); + expectAuditEvents(securityExtension!, aliases, { error: false }); expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledTimes(1); expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledWith(aliases); }); diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts index c33686a0f8cfa..7e8adf5cb8ec9 100644 --- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts @@ -7,7 +7,9 @@ import Boom from '@hapi/boom'; -import type { KibanaRequest, SavedObjectsErrorHelpers } from '@kbn/core/server'; +import type { ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server'; +import { AuditAction } from '@kbn/core-saved-objects-server'; +import type { KibanaRequest, SavedObjectsClient } from '@kbn/core/server'; import type { GetAllSpacesOptions, GetAllSpacesPurpose, @@ -19,11 +21,9 @@ import type { import { ALL_SPACES_ID } from '../../common/constants'; import type { AuditLogger } from '../audit'; -import { SavedObjectAction, savedObjectEvent, SpaceAuditAction, spaceAuditEvent } from '../audit'; +import { SpaceAuditAction, spaceAuditEvent } from '../audit'; import type { AuthorizationServiceSetup } from '../authorization'; import type { SecurityPluginSetup } from '../plugin'; -import type { EnsureAuthorizedDependencies, EnsureAuthorizedOptions } from '../saved_objects'; -import { ensureAuthorized, isAuthorizedForObjectInAllSpaces } from '../saved_objects'; const PURPOSE_PRIVILEGE_MAP: Record< GetAllSpacesPurpose, @@ -52,7 +52,8 @@ export class SecureSpacesClientWrapper implements ISpacesClient { private readonly request: KibanaRequest, private readonly authorization: AuthorizationServiceSetup, private readonly auditLogger: AuditLogger, - private readonly errors: typeof SavedObjectsErrorHelpers + private readonly errors: SavedObjectsClient['errors'], + private readonly securityExtension: ISavedObjectsSecurityExtension | undefined ) { this.useRbac = this.authorization.mode.useRbacForRequest(this.request); } @@ -271,7 +272,9 @@ export class SecureSpacesClientWrapper implements ISpacesClient { } // Fetch saved objects to be removed for audit logging - if (this.auditLogger.enabled) { + // If RBAC is enabled, the securityExtension should definitely be defined, but we check just in case + const securityExtension = this.securityExtension; + if (this.auditLogger.enabled && securityExtension !== undefined) { const finder = this.spacesClient.createSavedObjectFinder(id); try { for await (const response of finder.find()) { @@ -282,16 +285,12 @@ export class SecureSpacesClientWrapper implements ISpacesClient { // This object exists in All Spaces and its `namespaces` field isn't going to change; there's nothing to audit return; } - this.auditLogger.log( - savedObjectEvent({ - action: isOnlySpace - ? SavedObjectAction.DELETE - : SavedObjectAction.UPDATE_OBJECTS_SPACES, - outcome: 'unknown', - savedObject: { type: savedObject.type, id: savedObject.id }, - deleteFromSpaces: [id], - }) - ); + securityExtension.addAuditEvent({ + action: isOnlySpace ? AuditAction.DELETE : AuditAction.UPDATE_OBJECTS_SPACES, + outcome: 'unknown', + savedObject: { type: savedObject.type, id: savedObject.id }, + ...(!isOnlySpace && { deleteFromSpaces: [id] }), + }); }); } } finally { @@ -311,83 +310,52 @@ export class SecureSpacesClientWrapper implements ISpacesClient { } public async disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]) { - if (this.useRbac) { - try { - const [uniqueSpaces, uniqueTypes, typesAndSpacesMap] = aliases.reduce( - ([spaces, types, typesAndSpaces], { targetSpace, targetType }) => { - const spacesForType = typesAndSpaces.get(targetType) ?? new Set(); - return [ - spaces.add(targetSpace), - types.add(targetType), - typesAndSpaces.set(targetType, spacesForType.add(targetSpace)), - ]; - }, - [new Set(), new Set(), new Map>()] - ); + if (this.securityExtension) { + const [uniqueSpaces, typesAndSpaces] = aliases.reduce( + ([spaces, typesAndSpacesMap], { targetSpace, targetType }) => { + const spacesForType = typesAndSpacesMap.get(targetType) ?? new Set(); + return [ + spaces.add(targetSpace), + typesAndSpacesMap.set(targetType, spacesForType.add(targetSpace)), + ]; + }, + [new Set(), new Map>()] + ); - const action = 'bulk_update'; - const { typeActionMap } = await this.ensureAuthorizedForSavedObjects( - Array.from(uniqueTypes), - [action], - Array.from(uniqueSpaces), - { requireFullAuthorization: false } + const { typeMap } = await this.securityExtension.checkAuthorization({ + types: new Set(typesAndSpaces.keys()), + spaces: uniqueSpaces, + actions: new Set(['bulk_update']), + }); + let error: Error | undefined; + try { + await this.securityExtension.enforceAuthorization({ + typesAndSpaces, + action: 'bulk_update', + typeMap, + }); + } catch (err) { + error = this.errors.decorateForbiddenError( + new Error(`Unable to disable aliases: ${err.message}`) ); - const unauthorizedTypes = new Set(); - for (const type of uniqueTypes) { - const spaces = Array.from(typesAndSpacesMap.get(type)!); - if (!isAuthorizedForObjectInAllSpaces(type, action, typeActionMap, spaces)) { - unauthorizedTypes.add(type); - } - } - if (unauthorizedTypes.size > 0) { - const targetTypes = Array.from(unauthorizedTypes).sort().join(','); - const msg = `Unable to disable aliases for ${targetTypes}`; - throw this.errors.decorateForbiddenError(new Error(msg)); - } - } catch (error) { - aliases.forEach((alias) => { - const id = getAliasId(alias); - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.UPDATE, - savedObject: { type: LEGACY_URL_ALIAS_TYPE, id }, - error, - }) - ); + } + for (const alias of aliases) { + const id = getAliasId(alias); + this.securityExtension.addAuditEvent({ + action: AuditAction.UPDATE, + savedObject: { type: LEGACY_URL_ALIAS_TYPE, id }, + error, + ...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update operation has not occurred yet }); + } + if (error) { throw error; } } - aliases.forEach((alias) => { - const id = getAliasId(alias); - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.UPDATE, - outcome: 'unknown', - savedObject: { type: LEGACY_URL_ALIAS_TYPE, id }, - }) - ); - }); - return this.spacesClient.disableLegacyUrlAliases(aliases); } - private async ensureAuthorizedForSavedObjects( - types: string[], - actions: T[], - namespaces: string[], - options?: EnsureAuthorizedOptions - ) { - const ensureAuthorizedDependencies: EnsureAuthorizedDependencies = { - actions: this.authorization.actions, - errors: this.errors, - checkSavedObjectsPrivilegesAsCurrentUser: - this.authorization.checkSavedObjectsPrivilegesWithRequest(this.request), - }; - return ensureAuthorized(ensureAuthorizedDependencies, types, actions, namespaces, options); - } - private async ensureAuthorizedGlobally(action: string, forbiddenMessage: string) { const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request); const { hasAllRequested } = await checkPrivileges.globally({ kibana: action }); diff --git a/x-pack/plugins/security/server/spaces/setup_spaces_client.ts b/x-pack/plugins/security/server/spaces/setup_spaces_client.ts index a13c7f6434365..b2871c8bc7ef1 100644 --- a/x-pack/plugins/security/server/spaces/setup_spaces_client.ts +++ b/x-pack/plugins/security/server/spaces/setup_spaces_client.ts @@ -10,6 +10,7 @@ import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import type { AuditServiceSetup } from '../audit'; import type { AuthorizationServiceSetup } from '../authorization'; +import { SavedObjectsSecurityExtension } from '../saved_objects'; import { SecureSpacesClientWrapper } from './secure_spaces_client_wrapper'; interface Deps { @@ -31,14 +32,22 @@ export const setupSpacesClient = ({ audit, authz, spaces }: Deps) => { return savedObjectsStart.createScopedRepository(request, ['space']); }); - spacesClient.registerClientWrapper( - (request, baseClient) => - new SecureSpacesClientWrapper( - baseClient, - request, - authz, - audit.asScoped(request), - SavedObjectsClient.errors - ) - ); + spacesClient.registerClientWrapper((request, baseClient) => { + const securityExtension = authz.mode.useRbacForRequest(request) + ? new SavedObjectsSecurityExtension({ + actions: authz.actions, + auditLogger: audit.asScoped(request), + checkPrivileges: authz.checkSavedObjectsPrivilegesWithRequest(request), + errors: SavedObjectsClient.errors, + }) + : undefined; + return new SecureSpacesClientWrapper( + baseClient, + request, + authz, + audit.asScoped(request), + SavedObjectsClient.errors, + securityExtension + ); + }); }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts b/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts index 092721b5e0765..195c8509e60d5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.ts @@ -19,6 +19,7 @@ import type { PackagePolicy } from '@kbn/fleet-plugin/common'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '@kbn/fleet-plugin/common'; import type { PackagePolicyClient } from '@kbn/fleet-plugin/server'; import type { ILicense } from '@kbn/licensing-plugin/common/types'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import { isEndpointPolicyValidForLicense, unsetPolicyFeaturesAccordingToLicenseLevel, @@ -61,7 +62,7 @@ export class PolicyWatcher { url: { href: {} }, raw: { req: { url: '/' } }, } as unknown as KibanaRequest; - return soStart.getScopedClient(fakeRequest, { excludedWrappers: ['security'] }); + return soStart.getScopedClient(fakeRequest, { excludedExtensions: [SECURITY_EXTENSION_ID] }); } public start(licenseService: LicenseService) { diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/create_internal_readonly_so_client.ts b/x-pack/plugins/security_solution/server/endpoint/utils/create_internal_readonly_so_client.ts index 9866e866a7ea3..d8bf7badec846 100644 --- a/x-pack/plugins/security_solution/server/endpoint/utils/create_internal_readonly_so_client.ts +++ b/x-pack/plugins/security_solution/server/endpoint/utils/create_internal_readonly_so_client.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import type { KibanaRequest, SavedObjectsClientContract, @@ -46,7 +47,7 @@ export const createInternalReadonlySoClient = ( } as unknown as KibanaRequest; const internalSoClient = savedObjectsServiceStart.getScopedClient(fakeRequest, { - excludedWrappers: ['security'], + excludedExtensions: [SECURITY_EXTENSION_ID], }); return new Proxy(internalSoClient, { diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/saved_objects_client_opts.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/saved_objects_client_opts.ts index f548f5e2f6c99..122d1dbd182b4 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/saved_objects_client_opts.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/saved_objects_client_opts.ts @@ -5,8 +5,9 @@ * 2.0. */ +import { SPACES_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import type { SavedObjectsClientProviderOptions } from '@kbn/core/server'; export const COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS: SavedObjectsClientProviderOptions = { - excludedWrappers: ['spaces'], + excludedExtensions: [SPACES_EXTENSION_ID], }; diff --git a/x-pack/plugins/spaces/server/plugin.test.ts b/x-pack/plugins/spaces/server/plugin.test.ts index 024ae1e60100a..de448e962ad68 100644 --- a/x-pack/plugins/spaces/server/plugin.test.ts +++ b/x-pack/plugins/spaces/server/plugin.test.ts @@ -39,6 +39,7 @@ describe('Spaces plugin', () => { `); }); + // Joe removed this test, but we're not sure why... it('registers the capabilities provider and switcher', () => { const initializerContext = coreMock.createPluginInitializerContext({}); const core = coreMock.createSetup() as CoreSetup; @@ -53,7 +54,7 @@ describe('Spaces plugin', () => { expect(core.capabilities.registerSwitcher).toHaveBeenCalledTimes(1); }); - it('registers the usage collector', () => { + it('registers the usage collector if the usageCollection plugin is enabled', () => { const initializerContext = coreMock.createPluginInitializerContext({}); const core = coreMock.createSetup() as CoreSetup; const features = featuresPluginMock.createSetup(); @@ -67,31 +68,6 @@ describe('Spaces plugin', () => { expect(usageCollection.getCollectorByType('spaces')).toBeDefined(); }); - - it('registers the "space" saved object type and client wrapper', () => { - const initializerContext = coreMock.createPluginInitializerContext({}); - const core = coreMock.createSetup() as CoreSetup; - const features = featuresPluginMock.createSetup(); - const licensing = licensingMock.createSetup(); - - const plugin = new SpacesPlugin(initializerContext); - - plugin.setup(core, { features, licensing }); - - expect(core.savedObjects.registerType).toHaveBeenCalledWith({ - name: 'space', - namespaceType: 'agnostic', - hidden: true, - mappings: expect.any(Object), - migrations: expect.any(Object), - }); - - expect(core.savedObjects.addClientWrapper).toHaveBeenCalledWith( - Number.MIN_SAFE_INTEGER, - 'spaces', - expect.any(Function) - ); - }); }); describe('#start', () => { diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index 876c9b772fddf..f912ca209f363 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -8,6 +8,7 @@ import * as Rx from 'rxjs'; import type { ObjectType } from '@kbn/config-schema'; +import { SPACES_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import type { RouteValidatorConfig } from '@kbn/core/server'; import { kibanaResponseFactory } from '@kbn/core/server'; import { @@ -163,7 +164,7 @@ describe('copy to space', () => { await copyToSpace.routeHandler(mockRouteContext, request, kibanaResponseFactory); expect(coreStart.savedObjects.getScopedClient).toHaveBeenCalledWith(request, { - excludedWrappers: ['spaces'], + excludedExtensions: [SPACES_EXTENSION_ID], }); }); @@ -326,7 +327,7 @@ describe('copy to space', () => { await resolveConflicts.routeHandler(mockRouteContext, request, kibanaResponseFactory); expect(coreStart.savedObjects.getScopedClient).toHaveBeenCalledWith(request, { - excludedWrappers: ['spaces'], + excludedExtensions: [SPACES_EXTENSION_ID], }); }); diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_client_wrapper_factory.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_client_wrapper_factory.ts deleted file mode 100644 index 4e2a12955d702..0000000000000 --- a/x-pack/plugins/spaces/server/saved_objects/saved_objects_client_wrapper_factory.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { - SavedObjectsClientWrapperFactory, - SavedObjectsClientWrapperOptions, -} from '@kbn/core/server'; - -import type { SpacesServiceStart } from '../spaces_service/spaces_service'; -import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; - -export function spacesSavedObjectsClientWrapperFactory( - getSpacesService: () => SpacesServiceStart -): SavedObjectsClientWrapperFactory { - return (options: SavedObjectsClientWrapperOptions) => - new SpacesSavedObjectsClient({ - baseClient: options.client, - request: options.request, - getSpacesService, - typeRegistry: options.typeRegistry, - }); -} diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts index 27e632052dcb6..ef5f606da373c 100644 --- a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.test.ts @@ -31,19 +31,14 @@ describe('SpacesSavedObjectsService', () => { ); }); - it('registers the client wrapper', () => { + it('registers the spaces extension', () => { const core = coreMock.createSetup(); const spacesService = spacesServiceMock.createStartContract(); const service = new SpacesSavedObjectsService(); service.setup({ core, getSpacesService: () => spacesService }); - expect(core.savedObjects.addClientWrapper).toHaveBeenCalledTimes(1); - expect(core.savedObjects.addClientWrapper).toHaveBeenCalledWith( - Number.MIN_SAFE_INTEGER, - 'spaces', - expect.any(Function) - ); + expect(core.savedObjects.setSpacesExtension).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts index 9a90a26a647d3..7537249103186 100644 --- a/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_service.ts @@ -11,7 +11,7 @@ import type { SpacesServiceStart } from '../spaces_service'; import { SPACES_USAGE_STATS_TYPE } from '../usage_stats'; import { SpacesSavedObjectMappings, UsageStatsMappings } from './mappings'; import { spaceMigrations, usageStatsMigrations } from './migrations'; -import { spacesSavedObjectsClientWrapperFactory } from './saved_objects_client_wrapper_factory'; +import { SavedObjectsSpacesExtension } from './saved_objects_spaces_extension'; interface SetupDeps { core: Pick; @@ -40,10 +40,11 @@ export class SpacesSavedObjectsService { }, }); - core.savedObjects.addClientWrapper( - Number.MIN_SAFE_INTEGER, - 'spaces', - spacesSavedObjectsClientWrapperFactory(getSpacesService) - ); + core.savedObjects.setSpacesExtension(({ request }) => { + const spacesService = getSpacesService(); + const spacesClient = spacesService.createSpacesClient(request); + const activeSpaceId = spacesService.getSpaceId(request); + return new SavedObjectsSpacesExtension({ spacesClient, activeSpaceId }); + }); } } diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_spaces_extension.test.mocks.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_spaces_extension.test.mocks.ts new file mode 100644 index 0000000000000..048b76b406bcf --- /dev/null +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_spaces_extension.test.mocks.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { spaceIdToNamespace } from '../lib/utils/namespace'; + +export const mockSpaceIdToNamespace = jest.fn() as jest.MockedFunction; + +jest.mock('../lib/utils/namespace', () => { + return { + spaceIdToNamespace: mockSpaceIdToNamespace, + }; +}); diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_spaces_extension.test.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_spaces_extension.test.ts new file mode 100644 index 0000000000000..4dc6ae8cd2497 --- /dev/null +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_spaces_extension.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockSpaceIdToNamespace } from './saved_objects_spaces_extension.test.mocks'; + +import Boom from '@hapi/boom'; + +import { spacesClientMock } from '../mocks'; +import { SavedObjectsSpacesExtension } from './saved_objects_spaces_extension'; + +const ACTIVE_SPACE_ID = 'active-spaceId'; +function setup() { + const spacesClient = spacesClientMock.create(); + const spacesExtension = new SavedObjectsSpacesExtension({ + activeSpaceId: ACTIVE_SPACE_ID, + spacesClient, + }); + return { spacesClient, spacesExtension }; +} + +beforeAll(() => { + mockSpaceIdToNamespace.mockImplementation((spaceId) => `namespace-for-${spaceId}`); +}); + +describe('#getCurrentNamespace', () => { + test('throws an error when the namespace parameter is truthy', () => { + const { spacesExtension } = setup(); + expect(() => spacesExtension.getCurrentNamespace('some-namespace')).toThrowError( + 'Namespace cannot be specified by the caller when the spaces extension is enabled. Spaces currently determines the namespace.' + ); + }); + + test('returns namespace for the active space ID when the namespace parameter is falsy', () => { + const { spacesExtension } = setup(); + const result = spacesExtension.getCurrentNamespace(undefined); + expect(result).toEqual('namespace-for-active-spaceId'); + }); +}); + +describe('#getSearchableNamespaces', () => { + test(`returns empty result if user is unauthorized in this space`, async () => { + const { spacesClient, spacesExtension } = setup(); + spacesClient.getAll.mockImplementation(() => + Promise.resolve([ + { id: 'ns-1', name: '', disabledFeatures: [] }, + { id: 'ns-2', name: '', disabledFeatures: [] }, + { id: 'ns-3', name: '', disabledFeatures: [] }, + { id: 'ns-4', name: '', disabledFeatures: [] }, + ]) + ); + await expect(spacesExtension.getSearchableNamespaces(['some-namespace'])).resolves.toEqual([]); + }); + + test(`throws an error if user is unauthorized in any space`, async () => { + const { spacesClient, spacesExtension } = setup(); + spacesClient.getAll.mockRejectedValue(Boom.forbidden()); + await expect(spacesExtension.getSearchableNamespaces(['some-namespace'])).rejects.toThrow( + 'Forbidden' + ); + }); + + test(`returns the active namespace if the namespaces argument is undefined`, async () => { + const { spacesClient, spacesExtension } = setup(); + spacesClient.getAll.mockImplementation(() => + Promise.resolve([ + { id: 'ns-1', name: '', disabledFeatures: [] }, + { id: 'ns-2', name: '', disabledFeatures: [] }, + { id: 'ns-3', name: '', disabledFeatures: [] }, + { id: 'ns-4', name: '', disabledFeatures: [] }, + ]) + ); + await expect(spacesExtension.getSearchableNamespaces(undefined)).resolves.toEqual([ + ACTIVE_SPACE_ID, + ]); + }); + + test(`returns an empty array if the namespaces argument is an empty array`, async () => { + const { spacesClient, spacesExtension } = setup(); + spacesClient.getAll.mockImplementation(() => + Promise.resolve([ + { id: 'ns-1', name: '', disabledFeatures: [] }, + { id: 'ns-2', name: '', disabledFeatures: [] }, + { id: 'ns-3', name: '', disabledFeatures: [] }, + { id: 'ns-4', name: '', disabledFeatures: [] }, + ]) + ); + await expect(spacesExtension.getSearchableNamespaces([])).resolves.toEqual([]); + }); + + test(`filters results based on requested namespaces`, async () => { + const { spacesClient, spacesExtension } = setup(); + spacesClient.getAll.mockImplementation(() => + Promise.resolve([ + { id: 'ns-1', name: '', disabledFeatures: [] }, + { id: 'ns-2', name: '', disabledFeatures: [] }, + { id: 'ns-3', name: '', disabledFeatures: [] }, + { id: 'ns-4', name: '', disabledFeatures: [] }, + ]) + ); + + await expect(spacesExtension.getSearchableNamespaces(['ns-1', 'ns-3'])).resolves.toEqual([ + 'ns-1', + 'ns-3', + ]); + }); + + test(`filters options.namespaces based on authorization`, async () => { + const { spacesClient, spacesExtension } = setup(); + spacesClient.getAll.mockImplementation(() => + Promise.resolve([ + { id: 'ns-1', name: '', disabledFeatures: [] }, + { id: 'ns-2', name: '', disabledFeatures: [] }, + ]) + ); + + await expect(spacesExtension.getSearchableNamespaces(['ns-1', 'ns-3'])).resolves.toEqual([ + 'ns-1', + ]); + }); + + test(`handles namespaces argument ['*']`, async () => { + const { spacesClient, spacesExtension } = setup(); + spacesClient.getAll.mockImplementation(() => + Promise.resolve([ + { id: 'ns-1', name: '', disabledFeatures: [] }, + { id: 'ns-2', name: '', disabledFeatures: [] }, + { id: 'ns-3', name: '', disabledFeatures: [] }, + { id: 'ns-4', name: '', disabledFeatures: [] }, + ]) + ); + + await expect(spacesExtension.getSearchableNamespaces(['*'])).resolves.toEqual([ + 'ns-1', + 'ns-2', + 'ns-3', + 'ns-4', + ]); + }); +}); diff --git a/x-pack/plugins/spaces/server/saved_objects/saved_objects_spaces_extension.ts b/x-pack/plugins/spaces/server/saved_objects/saved_objects_spaces_extension.ts new file mode 100644 index 0000000000000..4520ff5ae3353 --- /dev/null +++ b/x-pack/plugins/spaces/server/saved_objects/saved_objects_spaces_extension.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ISavedObjectsSpacesExtension } from '@kbn/core-saved-objects-server'; + +import { ALL_SPACES_ID } from '../../common/constants'; +import { spaceIdToNamespace } from '../lib/utils/namespace'; +import type { ISpacesClient } from '../spaces_client'; + +interface Params { + activeSpaceId: string; + spacesClient: ISpacesClient; +} + +export class SavedObjectsSpacesExtension implements ISavedObjectsSpacesExtension { + private readonly activeSpaceId: string; + private readonly spacesClient: ISpacesClient; + + constructor({ activeSpaceId, spacesClient }: Params) { + this.activeSpaceId = activeSpaceId; + this.spacesClient = spacesClient; + } + + getCurrentNamespace(namespace: string | undefined): string | undefined { + if (namespace) { + throw new Error( + 'Namespace cannot be specified by the caller when the spaces extension is enabled. Spaces currently determines the namespace.' + ); + } + return spaceIdToNamespace(this.activeSpaceId); + } + + async getSearchableNamespaces(namespaces: string[] | undefined): Promise { + if (!namespaces) { + // If no namespaces option was specified, fall back to the active space. + return [this.activeSpaceId]; + } else if (!namespaces.length) { + // If the namespaces option is empty, return early and let the consumer handle it appropriately. + return namespaces; + } + + const availableSpaces = await this.spacesClient.getAll({ purpose: 'findSavedObjects' }); + if (namespaces.includes(ALL_SPACES_ID)) { + return availableSpaces.map((space) => space.id); + } else { + return namespaces.filter((namespace) => + availableSpaces.some((space) => space.id === namespace) + ); + } + } +} diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts deleted file mode 100644 index 70a8628246b71..0000000000000 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ /dev/null @@ -1,865 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import Boom from '@hapi/boom'; - -import type { SavedObject, SavedObjectsType } from '@kbn/core/server'; -import { SavedObjectsErrorHelpers } from '@kbn/core/server'; -import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from '@kbn/core/server/mocks'; - -import { DEFAULT_SPACE_ID } from '../../common/constants'; -import { spacesClientMock } from '../spaces_client/spaces_client.mock'; -import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; -import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; - -const createMockRequest = () => ({}); - -const createMockClient = () => savedObjectsClientMock.create(); - -const createSpacesService = (spaceId: string) => { - return spacesServiceMock.createStartContract(spaceId); -}; - -const createMockResponse = () => ({ - id: 'logstash-*', - title: 'logstash-*', - type: 'logstash-type', - attributes: {}, - timeFieldName: '@timestamp', - notExpandable: true, - references: [], - score: 0, -}); - -const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; - -[ - { id: DEFAULT_SPACE_ID, expectedNamespace: undefined }, - { id: 'space_1', expectedNamespace: 'space_1' }, -].forEach((currentSpace) => { - describe(`${currentSpace.id} space`, () => { - const createSpacesSavedObjectsClient = () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = createSpacesService(currentSpace.id); - const spacesClient = spacesClientMock.create(); - spacesService.createSpacesClient.mockReturnValue(spacesClient); - const typeRegistry = savedObjectsTypeRegistryMock.create(); - typeRegistry.getAllTypes.mockReturnValue([ - // for test purposes we only need the names of the object types - { name: 'foo' }, - { name: 'bar' }, - { name: 'space' }, - ] as unknown as SavedObjectsType[]); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - getSpacesService: () => spacesService, - typeRegistry, - }); - return { client, baseClient, spacesClient, typeRegistry }; - }; - - describe('#get', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect(client.get('foo', '', { namespace: 'bar' })).rejects.toThrow( - ERROR_NAMESPACE_SPECIFIED - ); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = createMockResponse(); - baseClient.get.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const type = Symbol(); - const id = Symbol(); - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.get(type, id, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.get).toHaveBeenCalledWith(type, id, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#bulkResolve', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect(client.bulkResolve([], { namespace: 'bar' })).rejects.toThrow( - ERROR_NAMESPACE_SPECIFIED - ); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { resolved_objects: [] }; - baseClient.bulkResolve.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.bulkResolve([], options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.bulkResolve).toHaveBeenCalledWith([], { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#resolve', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect(client.resolve('foo', '', { namespace: 'bar' })).rejects.toThrow( - ERROR_NAMESPACE_SPECIFIED - ); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { - saved_object: createMockResponse(), - outcome: 'exactMatch' as 'exactMatch', // outcome doesn't matter, just including it for type safety - }; - baseClient.resolve.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const type = Symbol(); - const id = Symbol(); - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.resolve(type, id, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.resolve).toHaveBeenCalledWith(type, id, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#bulkGet', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - client.bulkGet([{ id: '', type: 'foo' }], { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { saved_objects: [createMockResponse()] }; - baseClient.bulkGet.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const objects = [{ type: 'foo' }]; - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.bulkGet(objects, options); - - expect(actualReturnValue).toEqual(expectedReturnValue); - expect(baseClient.bulkGet).toHaveBeenCalledWith(objects, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - expect(spacesClient.getAll).not.toHaveBeenCalled(); - }); - - test(`replaces object namespaces '*' with available spaces`, async () => { - const { client, baseClient, spacesClient, typeRegistry } = createSpacesSavedObjectsClient(); - spacesClient.getAll.mockResolvedValue([ - { id: 'available-space-a', name: 'a', disabledFeatures: [] }, - { id: 'available-space-b', name: 'b', disabledFeatures: [] }, - ]); - typeRegistry.isNamespaceAgnostic.mockImplementation((type) => type === 'foo'); - typeRegistry.isShareable.mockImplementation((type) => type === 'bar'); - // 'baz' is neither agnostic nor shareable, so it is isolated (namespaceType: 'single' or namespaceType: 'multiple-isolated') - baseClient.bulkGet.mockResolvedValue({ - saved_objects: [ - { type: 'foo', id: '1', key: 'val' }, - { type: 'bar', id: '2', key: 'val' }, - { type: 'baz', id: '3', key: 'val' }, // this should be replaced with a 400 error - { type: 'foo', id: '4', key: 'val' }, - { type: 'bar', id: '5', key: 'val' }, - { type: 'baz', id: '6', key: 'val' }, // this should not be replaced with a 400 error because the user did not search for it in '*' all spaces - ] as unknown as SavedObject[], - }); - - const objects = [ - { type: 'foo', id: '1', namespaces: ['*', 'this-is-ignored'] }, - { type: 'bar', id: '2', namespaces: ['*', 'this-is-ignored'] }, - { type: 'baz', id: '3', namespaces: ['*', 'this-is-ignored'] }, - { type: 'foo', id: '4', namespaces: ['another-space'] }, - { type: 'bar', id: '5', namespaces: ['another-space'] }, - { type: 'baz', id: '6', namespaces: ['another-space'] }, - ]; - const result = await client.bulkGet(objects); - - expect(result.saved_objects).toEqual([ - { type: 'foo', id: '1', key: 'val' }, - { type: 'bar', id: '2', key: 'val' }, - { - type: 'baz', - id: '3', - error: SavedObjectsErrorHelpers.createBadRequestError( - '"namespaces" can only specify a single space when used with space-isolated types' - ).output.payload, - }, - { type: 'foo', id: '4', key: 'val' }, - { type: 'bar', id: '5', key: 'val' }, - { type: 'baz', id: '6', key: 'val' }, - ]); - expect(baseClient.bulkGet).toHaveBeenCalledWith( - [ - { type: 'foo', id: '1', namespaces: ['available-space-a', 'available-space-b'] }, - { type: 'bar', id: '2', namespaces: ['available-space-a', 'available-space-b'] }, - { type: 'baz', id: '3', namespaces: ['available-space-a', 'available-space-b'] }, - // even if another space doesn't exist, it can be specified explicitly - { type: 'foo', id: '4', namespaces: ['another-space'] }, - { type: 'bar', id: '5', namespaces: ['another-space'] }, - { type: 'baz', id: '6', namespaces: ['another-space'] }, - ], - { namespace: currentSpace.expectedNamespace } - ); - expect(spacesClient.getAll).toHaveBeenCalledTimes(1); - }); - - test(`replaces object namespaces '*' with an empty array when the user doesn't have access to any spaces`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - spacesClient.getAll.mockRejectedValue(Boom.forbidden()); - baseClient.bulkGet.mockResolvedValue({ saved_objects: [] }); // doesn't matter for this test - - const objects = [ - { type: 'foo', id: '1', namespaces: ['*'] }, - { type: 'bar', id: '2', namespaces: ['*', 'this-is-ignored'] }, - { type: 'baz', id: '3', namespaces: ['another-space'] }, - ]; - await client.bulkGet(objects); - - expect(baseClient.bulkGet).toHaveBeenCalledWith( - [ - { type: 'foo', id: '1', namespaces: [] }, - { type: 'bar', id: '2', namespaces: [] }, - { type: 'baz', id: '3', namespaces: ['another-space'] }, // even if another space doesn't exist, it can be specified explicitly - ], - { namespace: currentSpace.expectedNamespace } - ); - expect(spacesClient.getAll).toHaveBeenCalledTimes(1); - }); - }); - - describe('#find', () => { - const EMPTY_RESPONSE = { saved_objects: [], total: 0, per_page: 20, page: 1 }; - - test(`returns empty result if user is unauthorized in this space`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - spacesClient.getAll.mockResolvedValue([]); - - const options = Object.freeze({ type: 'foo', namespaces: ['some-ns'] }); - const actualReturnValue = await client.find(options); - - expect(actualReturnValue).toEqual(EMPTY_RESPONSE); - expect(baseClient.find).not.toHaveBeenCalled(); - }); - - test(`returns empty result if user is unauthorized in any space`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - spacesClient.getAll.mockRejectedValue(Boom.forbidden()); - - const options = Object.freeze({ type: 'foo', namespaces: ['some-ns'] }); - const actualReturnValue = await client.find(options); - - expect(actualReturnValue).toEqual(EMPTY_RESPONSE); - expect(baseClient.find).not.toHaveBeenCalled(); - }); - - test(`passes options.type to baseClient if valid singular type specified`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { - saved_objects: [createMockResponse()].map((obj) => ({ ...obj, score: 1 })), - total: 1, - per_page: 0, - page: 0, - }; - baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const options = Object.freeze({ type: 'foo' }); - const actualReturnValue = await client.find(options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.find).toHaveBeenCalledWith({ - type: ['foo'], - namespaces: [currentSpace.expectedNamespace ?? 'default'], - }); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { - saved_objects: [createMockResponse()].map((obj) => ({ ...obj, score: 1 })), - total: 1, - per_page: 0, - page: 0, - }; - baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const options = Object.freeze({ type: ['foo', 'bar'] }); - const actualReturnValue = await client.find(options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.find).toHaveBeenCalledWith({ - type: ['foo', 'bar'], - namespaces: [currentSpace.expectedNamespace ?? 'default'], - }); - }); - - test(`passes options.namespaces along`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { - saved_objects: [createMockResponse()], - total: 1, - per_page: 0, - page: 0, - }; - baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - - spacesClient.getAll.mockImplementation(() => - Promise.resolve([ - { id: 'ns-1', name: '', disabledFeatures: [] }, - { id: 'ns-2', name: '', disabledFeatures: [] }, - ]) - ); - - const options = Object.freeze({ type: ['foo', 'bar'], namespaces: ['ns-1', 'ns-2'] }); - const actualReturnValue = await client.find(options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.find).toHaveBeenCalledWith({ - type: ['foo', 'bar'], - namespaces: ['ns-1', 'ns-2'], - }); - expect(spacesClient.getAll).toHaveBeenCalledWith({ purpose: 'findSavedObjects' }); - }); - - test(`filters options.namespaces based on authorization`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { - saved_objects: [createMockResponse()], - total: 1, - per_page: 0, - page: 0, - }; - baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - - spacesClient.getAll.mockImplementation(() => - Promise.resolve([ - { id: 'ns-1', name: '', disabledFeatures: [] }, - { id: 'ns-2', name: '', disabledFeatures: [] }, - ]) - ); - - const options = Object.freeze({ type: ['foo', 'bar'], namespaces: ['ns-1', 'ns-3'] }); - const actualReturnValue = await client.find(options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.find).toHaveBeenCalledWith({ - type: ['foo', 'bar'], - namespaces: ['ns-1'], - }); - expect(spacesClient.getAll).toHaveBeenCalledWith({ purpose: 'findSavedObjects' }); - }); - - test(`translates options.namespace: ['*']`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { - saved_objects: [createMockResponse()], - total: 1, - per_page: 0, - page: 0, - }; - baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - - spacesClient.getAll.mockImplementation(() => - Promise.resolve([ - { id: 'ns-1', name: '', disabledFeatures: [] }, - { id: 'ns-2', name: '', disabledFeatures: [] }, - ]) - ); - - const options = Object.freeze({ type: ['foo', 'bar'], namespaces: ['*'] }); - const actualReturnValue = await client.find(options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.find).toHaveBeenCalledWith({ - type: ['foo', 'bar'], - namespaces: ['ns-1', 'ns-2'], - }); - expect(spacesClient.getAll).toHaveBeenCalledWith({ purpose: 'findSavedObjects' }); - }); - }); - - describe('#checkConflicts', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - // @ts-expect-error - client.checkConflicts(null, { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { errors: [] }; - baseClient.checkConflicts.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const objects = Symbol(); - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.checkConflicts(objects, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.checkConflicts).toHaveBeenCalledWith(objects, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#create', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect(client.create('foo', {}, { namespace: 'bar' })).rejects.toThrow( - ERROR_NAMESPACE_SPECIFIED - ); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = createMockResponse(); - baseClient.create.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const type = Symbol(); - const attributes = Symbol(); - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.create(type, attributes, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.create).toHaveBeenCalledWith(type, attributes, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#bulkCreate', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - client.bulkCreate([{ id: '', type: 'foo', attributes: {} }], { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { saved_objects: [createMockResponse()] }; - baseClient.bulkCreate.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const objects = [{ type: 'foo' }]; - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.bulkCreate(objects, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.bulkCreate).toHaveBeenCalledWith(objects, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#update', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - // @ts-expect-error - client.update(null, null, null, { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = createMockResponse(); - baseClient.update.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const type = Symbol(); - const id = Symbol(); - const attributes = Symbol(); - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.update(type, id, attributes, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.update).toHaveBeenCalledWith(type, id, attributes, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#bulkUpdate', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - // @ts-expect-error - client.bulkUpdate(null, { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { saved_objects: [createMockResponse()] }; - baseClient.bulkUpdate.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const actualReturnValue = await client.bulkUpdate([ - { id: 'id', type: 'foo', attributes: {}, references: [] }, - ]); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.bulkUpdate).toHaveBeenCalledWith( - [ - { - id: 'id', - type: 'foo', - attributes: {}, - references: [], - }, - ], - { namespace: currentSpace.expectedNamespace } - ); - }); - }); - - describe('#delete', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - // @ts-expect-error - client.delete(null, null, { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = createMockResponse(); - baseClient.delete.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const type = Symbol(); - const id = Symbol(); - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.delete(type, id, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.delete).toHaveBeenCalledWith(type, id, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#bulkDelete', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - // @ts-expect-error - client.bulkDelete(null, { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { statuses: [{ id: 'id', type: 'type', success: true }] }; - baseClient.bulkDelete.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const actualReturnValue = await client.bulkDelete([{ id: 'id', type: 'foo' }], { - force: true, - }); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.bulkDelete).toHaveBeenCalledWith( - [ - { - id: 'id', - type: 'foo', - }, - ], - { - namespace: currentSpace.expectedNamespace, - force: true, - } - ); - }); - }); - - describe('#removeReferencesTo', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - // @ts-expect-error - client.removeReferencesTo(null, null, { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { updated: 12 }; - baseClient.removeReferencesTo.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const type = Symbol(); - const id = Symbol(); - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.removeReferencesTo(type, id, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.removeReferencesTo).toHaveBeenCalledWith(type, id, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#openPointInTimeForType', () => { - test(`throws error if if user is unauthorized in this space`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - spacesClient.getAll.mockResolvedValue([]); - - await expect( - client.openPointInTimeForType('foo', { namespaces: ['bar'] }) - ).rejects.toThrowError('Bad Request'); - - expect(baseClient.openPointInTimeForType).not.toHaveBeenCalled(); - }); - - test(`throws error if if user is unauthorized in any space`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - spacesClient.getAll.mockRejectedValue(Boom.forbidden()); - - await expect( - client.openPointInTimeForType('foo', { namespaces: ['bar'] }) - ).rejects.toThrowError('Bad Request'); - - expect(baseClient.openPointInTimeForType).not.toHaveBeenCalled(); - }); - - test(`filters options.namespaces based on authorization`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { id: 'abc123' }; - baseClient.openPointInTimeForType.mockReturnValue(Promise.resolve(expectedReturnValue)); - - spacesClient.getAll.mockImplementation(() => - Promise.resolve([ - { id: 'ns-1', name: '', disabledFeatures: [] }, - { id: 'ns-2', name: '', disabledFeatures: [] }, - ]) - ); - - const options = Object.freeze({ namespaces: ['ns-1', 'ns-3'] }); - const actualReturnValue = await client.openPointInTimeForType(['foo', 'bar'], options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.openPointInTimeForType).toHaveBeenCalledWith(['foo', 'bar'], { - namespaces: ['ns-1'], - }); - expect(spacesClient.getAll).toHaveBeenCalledWith({ purpose: 'findSavedObjects' }); - }); - - test(`translates options.namespaces: ['*']`, async () => { - const { client, baseClient, spacesClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { id: 'abc123' }; - baseClient.openPointInTimeForType.mockReturnValue(Promise.resolve(expectedReturnValue)); - - spacesClient.getAll.mockImplementation(() => - Promise.resolve([ - { id: 'ns-1', name: '', disabledFeatures: [] }, - { id: 'ns-2', name: '', disabledFeatures: [] }, - ]) - ); - - const options = Object.freeze({ namespaces: ['*'] }); - const actualReturnValue = await client.openPointInTimeForType(['foo', 'bar'], options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.openPointInTimeForType).toHaveBeenCalledWith(['foo', 'bar'], { - namespaces: ['ns-1', 'ns-2'], - }); - expect(spacesClient.getAll).toHaveBeenCalledWith({ purpose: 'findSavedObjects' }); - }); - - test(`supplements options with the current namespace if unspecified`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { id: 'abc123' }; - baseClient.openPointInTimeForType.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const options = Object.freeze({ keepAlive: '2m' }); - const actualReturnValue = await client.openPointInTimeForType('foo', options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.openPointInTimeForType).toHaveBeenCalledWith('foo', { - keepAlive: '2m', - namespaces: [currentSpace.expectedNamespace ?? DEFAULT_SPACE_ID], - }); - }); - }); - - describe('#closePointInTime', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect(client.closePointInTime('foo', { namespace: 'bar' })).rejects.toThrow( - ERROR_NAMESPACE_SPECIFIED - ); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { succeeded: true, num_freed: 1 }; - baseClient.closePointInTime.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.closePointInTime('foo', options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.closePointInTime).toHaveBeenCalledWith('foo', { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#createPointInTimeFinder', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - const options = { type: ['a', 'b'], search: 'query', namespace: 'oops' }; - expect(() => client.createPointInTimeFinder(options)).toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - it('redirects request to underlying base client with default dependencies', () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - - const options = { type: ['a', 'b'], search: 'query' }; - client.createPointInTimeFinder(options); - - expect(baseClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); - expect(baseClient.createPointInTimeFinder).toHaveBeenCalledWith(options, { - client, - }); - }); - - it('redirects request to underlying base client with custom dependencies', () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - - const options = { type: ['a', 'b'], search: 'query' }; - const dependencies = { - client: { - find: jest.fn(), - openPointInTimeForType: jest.fn(), - closePointInTime: jest.fn(), - }, - }; - client.createPointInTimeFinder(options, dependencies); - - expect(baseClient.createPointInTimeFinder).toHaveBeenCalledTimes(1); - expect(baseClient.createPointInTimeFinder).toHaveBeenCalledWith(options, dependencies); - }); - }); - - describe('#collectMultiNamespaceReferences', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - client.collectMultiNamespaceReferences([], { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { objects: [] }; - baseClient.collectMultiNamespaceReferences.mockReturnValue( - Promise.resolve(expectedReturnValue) - ); - - const objects = [{ type: 'foo', id: 'bar' }]; - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.collectMultiNamespaceReferences(objects, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.collectMultiNamespaceReferences).toHaveBeenCalledWith(objects, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#updateObjectsSpaces', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect(client.updateObjectsSpaces([], [], [], { namespace: 'bar' })).rejects.toThrow( - ERROR_NAMESPACE_SPECIFIED - ); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { objects: [] }; - baseClient.updateObjectsSpaces.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const objects = [{ type: 'foo', id: 'bar' }]; - const spacesToAdd = ['space-x']; - const spacesToRemove = ['space-y']; - const options = Object.freeze({ foo: 'bar' }); - const actualReturnValue = await client.updateObjectsSpaces( - objects, - spacesToAdd, - spacesToRemove, - // @ts-expect-error - options - ); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.updateObjectsSpaces).toHaveBeenCalledWith( - objects, - spacesToAdd, - spacesToRemove, - { foo: 'bar', namespace: currentSpace.expectedNamespace } - ); - }); - }); - }); -}); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts deleted file mode 100644 index 52ca1f2604e88..0000000000000 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ /dev/null @@ -1,397 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import Boom from '@hapi/boom'; - -import type { - ISavedObjectTypeRegistry, - SavedObject, - SavedObjectsBaseOptions, - SavedObjectsBulkCreateObject, - SavedObjectsBulkDeleteObject, - SavedObjectsBulkDeleteOptions, - SavedObjectsBulkGetObject, - SavedObjectsBulkResolveObject, - SavedObjectsBulkUpdateObject, - SavedObjectsCheckConflictsObject, - SavedObjectsClientContract, - SavedObjectsClosePointInTimeOptions, - SavedObjectsCollectMultiNamespaceReferencesObject, - SavedObjectsCollectMultiNamespaceReferencesOptions, - SavedObjectsCollectMultiNamespaceReferencesResponse, - SavedObjectsCreateOptions, - SavedObjectsCreatePointInTimeFinderDependencies, - SavedObjectsCreatePointInTimeFinderOptions, - SavedObjectsFindOptions, - SavedObjectsOpenPointInTimeOptions, - SavedObjectsRemoveReferencesToOptions, - SavedObjectsUpdateObjectsSpacesObject, - SavedObjectsUpdateObjectsSpacesOptions, - SavedObjectsUpdateOptions, -} from '@kbn/core/server'; -import { SavedObjectsErrorHelpers, SavedObjectsUtils } from '@kbn/core/server'; - -import { ALL_SPACES_ID } from '../../common/constants'; -import { spaceIdToNamespace } from '../lib/utils/namespace'; -import type { ISpacesClient } from '../spaces_client'; -import type { SpacesServiceStart } from '../spaces_service/spaces_service'; - -interface Left { - tag: 'Left'; - value: L; -} - -interface Right { - tag: 'Right'; - value: R; -} - -type Either = Left | Right; -const isLeft = (either: Either): either is Left => either.tag === 'Left'; - -interface SpacesSavedObjectsClientOptions { - baseClient: SavedObjectsClientContract; - request: any; - getSpacesService: () => SpacesServiceStart; - typeRegistry: ISavedObjectTypeRegistry; -} - -const coerceToArray = (param: string | string[]) => { - if (Array.isArray(param)) { - return param; - } - - return [param]; -}; - -const throwErrorIfNamespaceSpecified = (options: any) => { - if (options.namespace) { - throw new Error('Spaces currently determines the namespaces'); - } -}; - -export class SpacesSavedObjectsClient implements SavedObjectsClientContract { - private readonly client: SavedObjectsClientContract; - private readonly typeRegistry: ISavedObjectTypeRegistry; - private readonly spaceId: string; - private readonly types: string[]; - private readonly spacesClient: ISpacesClient; - public readonly errors: typeof SavedObjectsErrorHelpers; - - constructor(options: SpacesSavedObjectsClientOptions) { - const { baseClient, request, getSpacesService, typeRegistry } = options; - - const spacesService = getSpacesService(); - - this.client = baseClient; - this.typeRegistry = typeRegistry; - this.spacesClient = spacesService.createSpacesClient(request); - this.spaceId = spacesService.getSpaceId(request); - this.types = typeRegistry.getAllTypes().map((t) => t.name); - this.errors = SavedObjectsErrorHelpers; - } - - async checkConflicts( - objects: SavedObjectsCheckConflictsObject[] = [], - options: SavedObjectsBaseOptions = {} - ) { - throwErrorIfNamespaceSpecified(options); - - return await this.client.checkConflicts(objects, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async create( - type: string, - attributes: T = {} as T, - options: SavedObjectsCreateOptions = {} - ) { - throwErrorIfNamespaceSpecified(options); - - return await this.client.create(type, attributes, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async bulkCreate( - objects: Array>, - options: SavedObjectsBaseOptions = {} - ) { - throwErrorIfNamespaceSpecified(options); - - return await this.client.bulkCreate(objects, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - throwErrorIfNamespaceSpecified(options); - - return await this.client.delete(type, id, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async bulkDelete( - objects: SavedObjectsBulkDeleteObject[] = [], - options: SavedObjectsBulkDeleteOptions = {} - ) { - throwErrorIfNamespaceSpecified(options); - return await this.client.bulkDelete(objects, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async find(options: SavedObjectsFindOptions) { - let namespaces: string[]; - try { - namespaces = await this.getSearchableSpaces(options.namespaces); - } catch (err) { - if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { - // return empty response, since the user is unauthorized in any space, but we don't return forbidden errors for `find` operations - return SavedObjectsUtils.createEmptyFindResponse(options); - } - throw err; - } - if (namespaces.length === 0) { - // return empty response, since the user is unauthorized in this space (or these spaces), but we don't return forbidden errors for `find` operations - return SavedObjectsUtils.createEmptyFindResponse(options); - } - - return await this.client.find({ - ...options, - type: (options.type ? coerceToArray(options.type) : this.types).filter( - (type) => type !== 'space' - ), - namespaces, - }); - } - - async bulkGet( - objects: SavedObjectsBulkGetObject[] = [], - options: SavedObjectsBaseOptions = {} - ) { - throwErrorIfNamespaceSpecified(options); - - let availableSpacesPromise: Promise | undefined; - const getAvailableSpaces = async () => { - if (!availableSpacesPromise) { - availableSpacesPromise = this.getSearchableSpaces([ALL_SPACES_ID]).catch((err) => { - if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { - return []; // the user doesn't have access to any spaces - } else { - throw err; - } - }); - } - return availableSpacesPromise; - }; - - const expectedResults = await Promise.all( - objects.map>>(async (object) => { - const { namespaces, type } = object; - if (namespaces?.includes(ALL_SPACES_ID)) { - // If searching for an isolated object in all spaces, we may need to return a 400 error for consistency with the validation at the - // repository level. This is needed if there is only one space available *and* the user is authorized to access the object in that - // space; in that case, we don't want to unintentionally bypass the repository's validation by deconstructing the '*' identifier - // into all available spaces. - const tag = - !this.typeRegistry.isNamespaceAgnostic(type) && !this.typeRegistry.isShareable(type) - ? 'Left' - : 'Right'; - return { tag, value: { ...object, namespaces: await getAvailableSpaces() } }; - } - return { tag: 'Right', value: object }; - }) - ); - - const objectsToGet = expectedResults.map(({ value }) => value); - const { saved_objects: responseObjects } = objectsToGet.length - ? await this.client.bulkGet(objectsToGet, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }) - : { saved_objects: [] }; - return { - saved_objects: expectedResults.map((expectedResult, i) => { - const actualResult = responseObjects[i]; - if (isLeft(expectedResult)) { - const { type, id } = expectedResult.value; - return { - type, - id, - error: SavedObjectsErrorHelpers.createBadRequestError( - '"namespaces" can only specify a single space when used with space-isolated types' - ).output.payload, - } as unknown as SavedObject; - } - return actualResult; - }), - }; - } - - async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - throwErrorIfNamespaceSpecified(options); - - return await this.client.get(type, id, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async bulkResolve( - objects: SavedObjectsBulkResolveObject[], - options: SavedObjectsBaseOptions = {} - ) { - throwErrorIfNamespaceSpecified(options); - - return await this.client.bulkResolve(objects, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async resolve(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - throwErrorIfNamespaceSpecified(options); - - return await this.client.resolve(type, id, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async update( - type: string, - id: string, - attributes: Partial, - options: SavedObjectsUpdateOptions = {} - ) { - throwErrorIfNamespaceSpecified(options); - - return await this.client.update(type, id, attributes, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async bulkUpdate( - objects: Array> = [], - options: SavedObjectsBaseOptions = {} - ) { - throwErrorIfNamespaceSpecified(options); - return await this.client.bulkUpdate(objects, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async removeReferencesTo( - type: string, - id: string, - options: SavedObjectsRemoveReferencesToOptions = {} - ) { - throwErrorIfNamespaceSpecified(options); - return await this.client.removeReferencesTo(type, id, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async collectMultiNamespaceReferences( - objects: SavedObjectsCollectMultiNamespaceReferencesObject[], - options: SavedObjectsCollectMultiNamespaceReferencesOptions = {} - ): Promise { - throwErrorIfNamespaceSpecified(options); - return await this.client.collectMultiNamespaceReferences(objects, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async updateObjectsSpaces( - objects: SavedObjectsUpdateObjectsSpacesObject[], - spacesToAdd: string[], - spacesToRemove: string[], - options: SavedObjectsUpdateObjectsSpacesOptions = {} - ) { - throwErrorIfNamespaceSpecified(options); - return await this.client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - async openPointInTimeForType( - type: string | string[], - options: SavedObjectsOpenPointInTimeOptions = {} - ) { - let namespaces: string[]; - try { - namespaces = await this.getSearchableSpaces(options.namespaces); - } catch (err) { - if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { - // throw bad request since the user is unauthorized in any space - throw SavedObjectsErrorHelpers.createBadRequestError(); - } - throw err; - } - if (namespaces.length === 0) { - // throw bad request if no valid spaces were found. - throw SavedObjectsErrorHelpers.createBadRequestError(); - } - - return await this.client.openPointInTimeForType(type, { - ...options, - namespaces, - }); - } - - async closePointInTime(id: string, options: SavedObjectsClosePointInTimeOptions = {}) { - throwErrorIfNamespaceSpecified(options); - return await this.client.closePointInTime(id, { - ...options, - namespace: spaceIdToNamespace(this.spaceId), - }); - } - - createPointInTimeFinder( - findOptions: SavedObjectsCreatePointInTimeFinderOptions, - dependencies?: SavedObjectsCreatePointInTimeFinderDependencies - ) { - throwErrorIfNamespaceSpecified(findOptions); - // We don't need to handle namespaces here, because `createPointInTimeFinder` - // is simply a helper that calls `find`, `openPointInTimeForType`, and - // `closePointInTime` internally, so namespaces will already be handled - // in those methods. - return this.client.createPointInTimeFinder(findOptions, { - client: this, - // Include dependencies last so that subsequent SO client wrappers have their settings applied. - ...dependencies, - }); - } - - private async getSearchableSpaces(namespaces?: string[]): Promise { - if (namespaces) { - const availableSpaces = await this.spacesClient.getAll({ purpose: 'findSavedObjects' }); - if (namespaces.includes(ALL_SPACES_ID)) { - return availableSpaces.map((space) => space.id); - } else { - return namespaces.filter((namespace) => - availableSpaces.some((space) => space.id === namespace) - ); - } - } else { - return [this.spaceId]; - } - } -} diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts index 786b24e5a3f79..783bf6a89a0b9 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/routes.ts @@ -23,6 +23,7 @@ import { TaskInstance, TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; +import { SECURITY_EXTENSION_ID, SPACES_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import { FixtureStartDeps } from './plugin'; import { retryIfConflicts } from './lib/retry_if_conflicts'; @@ -85,7 +86,7 @@ export function defineRoutes( const savedObjectsWithAlerts = await savedObjects.getScopedClient(req, { // Exclude the security and spaces wrappers to get around the safeguards those have in place to prevent // us from doing what we want to do - brute force replace the ApiKey - excludedWrappers: ['security', 'spaces'], + excludedExtensions: [SECURITY_EXTENSION_ID, SPACES_EXTENSION_ID], includedHiddenTypes: ['alert'], }); diff --git a/x-pack/test/common/lib/test_data_loader.ts b/x-pack/test/common/lib/test_data_loader.ts index 280c959e691bd..b379d4b61e3ba 100644 --- a/x-pack/test/common/lib/test_data_loader.ts +++ b/x-pack/test/common/lib/test_data_loader.ts @@ -71,6 +71,10 @@ const OBJECTS_TO_SHARE: Array<{ spacesToAdd: [SPACE_2.id], objects: [{ type: 'sharedtype', id: 'default_and_space_2' }], }, + { + spacesToAdd: [SPACE_1.id, SPACE_2.id], + objects: [{ type: 'resolvetype', id: 'conflict-newid' }], + }, ]; export function getTestDataLoader({ getService }: Pick) { diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index ce18ca4681310..3b4ee68fad892 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -68,7 +68,6 @@ import { } from './dashboard'; import { SearchSessionsService } from './search_sessions'; import { ObservabilityProvider } from './observability'; -// import { CompareImagesProvider } from './compare_images'; import { CasesServiceProvider } from './cases'; import { ActionsServiceProvider } from './actions'; import { RulesServiceProvider } from './rules'; diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/kbn_archiver/default_space.json b/x-pack/test/saved_object_api_integration/common/fixtures/kbn_archiver/default_space.json index 9a2713fc61872..6b348cddce5a5 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/kbn_archiver/default_space.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/kbn_archiver/default_space.json @@ -161,3 +161,13 @@ "version": "WzQ4OCwxXQ==" } +{ + "attributes": { + "title": "Resolve outcome conflict (2 of 2)" + }, + "id": "conflict-newid", + "type": "resolvetype", + "updated_at": "2017-09-21T18:51:23.794Z", + "version": "WzQ4OCwxXQ==" +} + diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_delete.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_delete.ts index d0c90ccc3c255..578f8a4e0cd1f 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_delete.ts @@ -5,14 +5,13 @@ * 2.0. */ -import { SuperTest } from 'supertest'; -import type { Client } from '@elastic/elasticsearch'; import expect from '@kbn/expect'; import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types'; +import { FtrProviderContext } from '../ftr_provider_context'; export interface BulkDeleteTestDefinition extends TestDefinition { request: { type: string; id: string; force?: boolean }; @@ -46,7 +45,12 @@ export const TEST_CASES: Record = Object.freeze({ */ const createRequest = ({ type, id, force }: BulkDeleteTestCase) => ({ type, id, force }); -export function bulkDeleteTestSuiteFactory(es: Client, esArchiver: any, supertest: SuperTest) { +export function bulkDeleteTestSuiteFactory(context: FtrProviderContext) { + const esArchiver = context.getService('esArchiver'); + const supertest = context.getService('supertestWithoutAuth'); + const es = context.getService('es'); + // const log = context.getService('log'); + const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_delete'); const expectResponseBody = (testCase: BulkDeleteTestCase, statusCode: 200 | 403, user?: TestUser): ExpectResponseBody => diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_resolve.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_resolve.ts index 7073fd99fdd47..a203865e294aa 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_resolve.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_resolve.ts @@ -135,5 +135,6 @@ export function bulkResolveTestSuiteFactory(esArchiver: any, supertest: SuperTes return { addTests, createTestDefinitions, + expectSavedObjectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts index 9be10e92438a9..ebb71a860d744 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts @@ -71,7 +71,7 @@ export const TEST_CASES = Object.freeze({ }); export function resolveTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectSavedObjectForbidden = expectResponses.forbiddenTypes('get'); + const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_get'); const expectResponseBody = (testCase: ResolveTestCase): ExpectResponseBody => async (response: Record) => { diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index bc75501d76b81..caf298a1c773d 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -85,6 +85,19 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { expectedNamespaces, }, ]; + const badRequests = [ + { ...CASES.HIDDEN, ...fail400() }, + { + ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, + initialNamespaces: ['x', 'y'], + ...fail400(), // cannot be created in multiple spaces -- second try below succeeds + }, + { + ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, + initialNamespaces: [ALL_SPACES_ID], + ...fail400(), // cannot be created in multiple spaces -- second try below succeeds + }, + ]; const crossNamespace = [ { ...CASES.ALIAS_CONFLICT_OBJ, @@ -93,77 +106,60 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { fail409Param: 'aliasConflictAllSpaces', // second try fails because an alias exists in space_x, the default space, and space_1 (but not space_y because that alias is disabled) // note that if an object was successfully created with this type/ID in the first try, that won't change this outcome, because an alias conflict supersedes all other types of conflicts }, - { - ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, - initialNamespaces: ['x', 'y'], - ...fail400(), // cannot be created in multiple spaces - }, CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid - { - ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, - initialNamespaces: [ALL_SPACES_ID], - ...fail400(), // cannot be created in multiple spaces - }, CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, ]; - const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; - const allTypes = [...normalTypes, ...crossNamespace, ...hiddenType]; - return { normalTypes, crossNamespace, hiddenType, allTypes }; + const allTypes = [...normalTypes, ...badRequests, ...crossNamespace]; + return { normalTypes, badRequests, crossNamespace, allTypes }; }; export default function (context: FtrProviderContext) { const { addTests, createTestDefinitions, expectSavedObjectForbidden } = bulkCreateTestSuiteFactory(context); const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => { - const { normalTypes, crossNamespace, hiddenType, allTypes } = createTestCases( + const { normalTypes, badRequests, crossNamespace, allTypes } = createTestCases( overwrite, spaceId ); // use singleRequest to reduce execution time and/or test combined cases - const authorizedCommon = [ - createTestDefinitions(normalTypes, false, overwrite, { - spaceId, - user, - singleRequest: true, - }), - createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), - ].flat(); + const singleRequest = true; return { - unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId, user }), - authorizedAtSpace: [ - authorizedCommon, + unauthorized: [ + createTestDefinitions(normalTypes, true, overwrite, { spaceId, user }), + createTestDefinitions(badRequests, false, overwrite, { spaceId, user, singleRequest }), // validation for hidden type and initialNamespaces returns 400 Bad Request before authZ check createTestDefinitions(crossNamespace, true, overwrite, { spaceId, user }), createTestDefinitions(allTypes, true, overwrite, { spaceId, user, - singleRequest: true, + singleRequest, + responseBodyOverride: expectSavedObjectForbidden([ + 'dashboard,globaltype,isolatedtype,resolvetype,sharecapabletype,sharedtype', // 'hiddentype' is not included in the 403 message, it was filtered out before the authZ check + ]), }), ].flat(), - authorizedEverywhere: [ - authorizedCommon, - createTestDefinitions(crossNamespace, false, overwrite, { - spaceId, - user, - singleRequest: true, - }), + authorizedAtSpace: [ + createTestDefinitions(normalTypes, false, overwrite, { spaceId, user, singleRequest }), + createTestDefinitions(badRequests, false, overwrite, { spaceId, user, singleRequest }), // validation for hidden type and initialNamespaces returns 400 Bad Request before authZ check + createTestDefinitions(crossNamespace, true, overwrite, { spaceId, user }), createTestDefinitions(allTypes, true, overwrite, { spaceId, user, - singleRequest: true, - responseBodyOverride: expectSavedObjectForbidden(['hiddentype']), + singleRequest, + responseBodyOverride: expectSavedObjectForbidden([ + 'dashboard,globaltype,isolatedtype,resolvetype,sharecapabletype,sharedtype', // 'hiddentype' is not included in the 403 message, it was filtered out before the authZ check + ]), }), ].flat(), - superuser: createTestDefinitions(allTypes, false, overwrite, { + authorizedEverywhere: createTestDefinitions(allTypes, false, overwrite, { spaceId, user, - singleRequest: true, + singleRequest, }), }; }; - // Failing: See https://github.com/elastic/kibana/issues/122827 describe('_bulk_create', () => { getTestScenarios([false, true]).securityAndSpaces.forEach( ({ spaceId, users, modifier: overwrite }) => { @@ -187,13 +183,10 @@ export default function (context: FtrProviderContext) { const { authorizedAtSpace } = createTests(overwrite!, spaceId, users.allAtSpace); _addTests(users.allAtSpace, authorizedAtSpace); - [users.dualAll, users.allGlobally].forEach((user) => { + [users.dualAll, users.allGlobally, users.superuser].forEach((user) => { const { authorizedEverywhere } = createTests(overwrite!, spaceId, user); _addTests(user, authorizedEverywhere); }); - - const { superuser } = createTests(overwrite!, spaceId, users.superuser); - _addTests(users.superuser, superuser); } ); }); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_delete.ts index 7a40ea564fb82..889cdd0d46d5f 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_delete.ts @@ -60,12 +60,9 @@ const createTestCases = (spaceId: string) => { return { normalTypes, hiddenType, allTypes }; }; -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - const es = getService('es'); +export default function (context: FtrProviderContext) { + const { addTests, createTestDefinitions } = bulkDeleteTestSuiteFactory(context); - const { addTests, createTestDefinitions } = bulkDeleteTestSuiteFactory(es, esArchiver, supertest); const createTests = (spaceId: string) => { const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); return { diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts index ed251440d361a..d8bc344fab109 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts @@ -45,38 +45,37 @@ const createTestCases = (spaceId: string) => { CASES.NAMESPACE_AGNOSTIC, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; - const crossNamespace = [ + const badRequests = [ + { ...CASES.HIDDEN, ...fail400() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespaces: ['x', 'y'], - ...fail400(), // cannot be searched for in multiple spaces + ...fail400(), // cannot be searched for in multiple spaces -- second try below succeeds }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespaces: [SPACE_2_ID] }, // second try searches for it in a single other space, which is valid { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, namespaces: [ALL_SPACES_ID], - ...fail400(), // cannot be searched for in multiple spaces + ...fail400(), // cannot be searched for in multiple spaces -- second try below succeeds }, + ]; + const crossNamespace = [ + { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespaces: [SPACE_2_ID] }, // second try searches for it in a single other space, which is valid { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, namespaces: [SPACE_1_ID] }, // second try searches for it in a single other space, which is valid { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespaces: [SPACE_2_ID], ...fail404() }, { ...CASES.MULTI_NAMESPACE_ALL_SPACES, namespaces: [SPACE_2_ID, 'x'] }, // unknown space is allowed / ignored { ...CASES.MULTI_NAMESPACE_ALL_SPACES, namespaces: [ALL_SPACES_ID] }, // this is different than the same test case in the spaces_only suite, since MULTI_NAMESPACE_ONLY_SPACE_1 *may* return a 404 error to a partially authorized user ]; - const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; - const allTypes = [...normalTypes, ...crossNamespace, ...hiddenType]; - return { normalTypes, crossNamespace, hiddenType, allTypes }; + const allTypes = [...normalTypes, ...badRequests, ...crossNamespace]; + return { normalTypes, badRequests, crossNamespace, allTypes }; }; export default function (context: FtrProviderContext) { const { addTests, createTestDefinitions, expectSavedObjectForbidden } = bulkGetTestSuiteFactory(context); const createTests = (spaceId: string) => { - const { normalTypes, crossNamespace, hiddenType, allTypes } = createTestCases(spaceId); + const { normalTypes, badRequests, crossNamespace, allTypes } = createTestCases(spaceId); // use singleRequest to reduce execution time and/or test combined cases - const authorizedCommon = [ - createTestDefinitions(normalTypes, false, { singleRequest: true }), - createTestDefinitions(hiddenType, true), - ].flat(); + const singleRequest = true; const crossNamespaceAuthorizedAtSpace = crossNamespace.reduce<{ authorized: BulkGetTestCase[]; unauthorized: BulkGetTestCase[]; @@ -92,32 +91,43 @@ export default function (context: FtrProviderContext) { ); return { - unauthorized: createTestDefinitions(allTypes, true), - authorizedAtSpace: [ - authorizedCommon, - createTestDefinitions(crossNamespaceAuthorizedAtSpace.authorized, false, { - singleRequest: true, + unauthorized: [ + createTestDefinitions(normalTypes, true), + createTestDefinitions(badRequests, false, { singleRequest }), // validation for hidden type and initialNamespaces returns 400 Bad Request before authZ check + createTestDefinitions(crossNamespace, true), + createTestDefinitions(allTypes, true, { + singleRequest, + responseBodyOverride: expectSavedObjectForbidden([ + 'dashboard,globaltype,isolatedtype,sharecapabletype,sharedtype', // 'hiddentype' is not included in the 403 message, it was filtered out before the authZ check + ]), }), - createTestDefinitions(crossNamespaceAuthorizedAtSpace.unauthorized, true), - createTestDefinitions(allTypes, true, { singleRequest: true }), ].flat(), - authorizedEverywhere: [ - authorizedCommon, - createTestDefinitions(crossNamespace, false, { singleRequest: true }), + authorizedAtSpace: [ + createTestDefinitions(normalTypes, false, { singleRequest }), + createTestDefinitions(badRequests, false, { singleRequest }), // validation for hidden type and initialNamespaces returns 400 Bad Request before authZ check + createTestDefinitions(crossNamespaceAuthorizedAtSpace.authorized, false, { singleRequest }), + createTestDefinitions(crossNamespaceAuthorizedAtSpace.unauthorized, true), createTestDefinitions(allTypes, true, { - singleRequest: true, - responseBodyOverride: expectSavedObjectForbidden(['hiddentype']), + singleRequest, + responseBodyOverride: expectSavedObjectForbidden( + spaceId === DEFAULT_SPACE_ID + ? // While the Default space is active, we always attempt to get 'sharecapabletype' (MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1) + // with a cross-namespace check in Space 1, and fail the authZ check for 'sharecapabletype' in Space 1 because these users + // are only authorized for the Default space! + // 'hiddentype' is not included in either 403 message, it was filtered out before the authZ check + ['isolatedtype,sharecapabletype,sharedtype'] + : ['isolatedtype,sharedtype'] + ), }), ].flat(), - superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), + authorizedEverywhere: createTestDefinitions(allTypes, false, { singleRequest }), }; }; describe('_bulk_get', () => { getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorizedAtSpace, authorizedEverywhere, superuser } = - createTests(spaceId); + const { unauthorized, authorizedAtSpace, authorizedEverywhere } = createTests(spaceId); const _addTests = (user: TestUser, tests: BulkGetTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; @@ -130,11 +140,15 @@ export default function (context: FtrProviderContext) { _addTests(user, authorizedAtSpace); }); - [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach((user) => { + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.superuser, + ].forEach((user) => { _addTests(user, authorizedEverywhere); }); - - _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_resolve.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_resolve.ts index ef4c54110a20a..5f6606ac07c59 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_resolve.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_resolve.ts @@ -44,24 +44,29 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - const { addTests, createTestDefinitions } = bulkResolveTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions, expectSavedObjectForbidden } = + bulkResolveTestSuiteFactory(esArchiver, supertest); const createTests = (spaceId: string) => { const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); // use singleRequest to reduce execution time and/or test combined cases + const singleRequest = true; return { - unauthorized: createTestDefinitions(allTypes, true), - authorized: [ - createTestDefinitions(normalTypes, false), - createTestDefinitions(hiddenType, true), + unauthorized: [ + createTestDefinitions(normalTypes, true), + createTestDefinitions(hiddenType, false, { singleRequest }), // validation for hidden type returns 400 Bad Request before authZ check + createTestDefinitions(allTypes, true, { + singleRequest, + responseBodyOverride: expectSavedObjectForbidden(['resolvetype']), // 'hiddentype' is not included in the 403 message, it was filtered out before the authZ check + }), ].flat(), - superuser: createTestDefinitions(allTypes, false), + authorized: createTestDefinitions(allTypes, false, { singleRequest }), }; }; describe('_bulk_resolve', () => { getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorized, superuser } = createTests(spaceId); + const { unauthorized, authorized } = createTests(spaceId); const _addTests = (user: TestUser, tests: BulkResolveTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; @@ -76,10 +81,10 @@ export default function ({ getService }: FtrProviderContext) { users.readGlobally, users.allAtSpace, users.readAtSpace, + users.superuser, ].forEach((user) => { _addTests(user, authorized); }); - _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts index 2d6b1160d9864..33a02fd783e75 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts @@ -45,7 +45,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; - const allTypes = normalTypes.concat(hiddenType); + const normalAndHidden = normalTypes.concat(hiddenType); // an "object namespace" string can be specified for individual objects (to bulkUpdate across namespaces) const withObjectNamespaces = [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespace: DEFAULT_SPACE_ID }, @@ -58,7 +58,7 @@ const createTestCases = (spaceId: string) => { CASES.NAMESPACE_AGNOSTIC, // any namespace would work and would make no difference { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; - return { normalTypes, hiddenType, allTypes, withObjectNamespaces }; + return { normalTypes, hiddenType, normalAndHidden, withObjectNamespaces }; }; export default function ({ getService }: FtrProviderContext) { @@ -68,32 +68,26 @@ export default function ({ getService }: FtrProviderContext) { const { addTests, createTestDefinitions, expectSavedObjectForbidden } = bulkUpdateTestSuiteFactory(esArchiver, supertest); const createTests = (spaceId: string) => { - const { normalTypes, hiddenType, allTypes, withObjectNamespaces } = createTestCases(spaceId); + const { normalTypes, hiddenType, normalAndHidden, withObjectNamespaces } = + createTestCases(spaceId); // use singleRequest to reduce execution time and/or test combined cases - const authorizedCommon = [ - createTestDefinitions(normalTypes, false, { singleRequest: true }), - createTestDefinitions(hiddenType, true), - createTestDefinitions(allTypes, true, { - singleRequest: true, - responseBodyOverride: expectSavedObjectForbidden(['hiddentype']), - }), - ].flat(); + const singleRequest = true; return { unauthorized: [ - createTestDefinitions(allTypes, true), - createTestDefinitions(withObjectNamespaces, true, { singleRequest: true }), + createTestDefinitions(normalTypes, true), + createTestDefinitions(hiddenType, false, { singleRequest }), // validation for hidden type returns 404 Not Found before authZ check + createTestDefinitions(withObjectNamespaces, true, { singleRequest }), ].flat(), authorizedAtSpace: [ - authorizedCommon, - createTestDefinitions(withObjectNamespaces, true, { singleRequest: true }), + createTestDefinitions(normalAndHidden, false, { singleRequest }), // validation for hidden type returns 404 Not Found before authZ check + createTestDefinitions(withObjectNamespaces, true, { + singleRequest, + responseBodyOverride: expectSavedObjectForbidden(['isolatedtype,sharedtype']), // 'dashboard' and 'globaltype' are not in the error message because this user *is* authorized to update those object types in that space + }), ].flat(), authorizedAllSpaces: [ - authorizedCommon, - createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }), - ].flat(), - superuser: [ - createTestDefinitions(allTypes, false, { singleRequest: true }), - createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }), + createTestDefinitions(normalAndHidden, false, { singleRequest }), // validation for hidden type returns 404 Not Found before authZ check + createTestDefinitions(withObjectNamespaces, false, { singleRequest }), ].flat(), }; }; @@ -101,8 +95,7 @@ export default function ({ getService }: FtrProviderContext) { describe('_bulk_update', () => { getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorizedAtSpace, authorizedAllSpaces, superuser } = - createTests(spaceId); + const { unauthorized, authorizedAtSpace, authorizedAllSpaces } = createTests(spaceId); const _addTests = (user: TestUser, tests: BulkUpdateTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; @@ -120,10 +113,9 @@ export default function ({ getService }: FtrProviderContext) { [users.allAtSpace].forEach((user) => { _addTests(user, authorizedAtSpace); }); - [users.dualAll, users.allGlobally].forEach((user) => { + [users.dualAll, users.allGlobally, users.superuser].forEach((user) => { _addTests(user, authorizedAllSpaces); }); - _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index cdef08e820416..cf9b118d4776d 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -64,26 +64,28 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { // We test the alias conflict preflight check error case twice; once by checking the alias with "find" and once by using "bulk-get". { ...CASES.ALIAS_CONFLICT_OBJ, ...fail409(spaceId !== SPACE_2_ID), expectedNamespaces }, // first try fails if this is the default space or space_1, because an alias exists in those spaces ]; - const crossNamespace = [ - { ...CASES.ALIAS_CONFLICT_OBJ, initialNamespaces: ['*'], ...fail409() }, // second try fails because an alias exists in space_x, the default space, and space_1 (but not space_y because that alias is disabled) + const badRequests = [ + { ...CASES.HIDDEN, ...fail400() }, { ...CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, initialNamespaces: ['x', 'y'], - ...fail400(), // cannot be created in multiple spaces + ...fail400(), // cannot be created in multiple spaces -- second try below succeeds }, - CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid { ...CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, initialNamespaces: [ALL_SPACES_ID], - ...fail400(), // cannot be created in multiple spaces + ...fail400(), // cannot be created in multiple spaces -- second try below succeeds }, + ]; + const crossNamespace = [ + { ...CASES.ALIAS_CONFLICT_OBJ, initialNamespaces: ['*'], ...fail409() }, // second try fails because an alias exists in space_x, the default space, and space_1 (but not space_y because that alias is disabled) + CASES.INITIAL_NS_SINGLE_NAMESPACE_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid CASES.INITIAL_NS_MULTI_NAMESPACE_ISOLATED_OBJ_OTHER_SPACE, // second try creates it in a single other space, which is valid CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_EACH_SPACE, CASES.INITIAL_NS_MULTI_NAMESPACE_OBJ_ALL_SPACES, ]; - const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; - const allTypes = normalTypes.concat(crossNamespace, hiddenType); - return { normalTypes, crossNamespace, hiddenType, allTypes }; + const allTypes = [...normalTypes, ...badRequests, ...crossNamespace]; + return { normalTypes, badRequests, crossNamespace, allTypes }; }; export default function ({ getService }: FtrProviderContext) { @@ -92,23 +94,22 @@ export default function ({ getService }: FtrProviderContext) { const { addTests, createTestDefinitions } = createTestSuiteFactory(esArchiver, supertest); const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => { - const { normalTypes, crossNamespace, hiddenType, allTypes } = createTestCases( + const { normalTypes, badRequests, crossNamespace, allTypes } = createTestCases( overwrite, spaceId ); return { - unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId, user }), - authorizedAtSpace: [ - createTestDefinitions(normalTypes, false, overwrite, { spaceId, user }), + unauthorized: [ + createTestDefinitions(normalTypes, true, overwrite, { spaceId, user }), + createTestDefinitions(badRequests, false, overwrite, { spaceId, user }), // validation for hidden type and initialNamespaces returns 400 Bad Request before authZ check createTestDefinitions(crossNamespace, true, overwrite, { spaceId, user }), - createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), ].flat(), - authorizedEverywhere: [ + authorizedAtSpace: [ createTestDefinitions(normalTypes, false, overwrite, { spaceId, user }), - createTestDefinitions(crossNamespace, false, overwrite, { spaceId, user }), - createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), + createTestDefinitions(badRequests, false, overwrite, { spaceId, user }), // validation for hidden type and initialNamespaces returns 400 Bad Request before authZ check + createTestDefinitions(crossNamespace, true, overwrite, { spaceId, user }), ].flat(), - superuser: createTestDefinitions(allTypes, false, overwrite, { spaceId, user }), + authorizedEverywhere: createTestDefinitions(allTypes, false, overwrite, { spaceId, user }), }; }; @@ -135,13 +136,10 @@ export default function ({ getService }: FtrProviderContext) { const { authorizedAtSpace } = createTests(overwrite!, spaceId, users.allAtSpace); _addTests(users.allAtSpace, authorizedAtSpace); - [users.dualAll, users.allGlobally].forEach((user) => { + [users.dualAll, users.allGlobally, users.superuser].forEach((user) => { const { authorizedEverywhere } = createTests(overwrite!, spaceId, user); _addTests(user, authorizedEverywhere); }); - - const { superuser } = createTests(overwrite!, spaceId, users.superuser); - _addTests(users.superuser, superuser); } ); }); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts index 8970070645f4d..37fe392db06ce 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -69,19 +69,18 @@ export default function ({ getService }: FtrProviderContext) { const createTests = (spaceId: string) => { const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); return { - unauthorized: createTestDefinitions(allTypes, true, { spaceId }), - authorized: [ - createTestDefinitions(normalTypes, false, { spaceId }), - createTestDefinitions(hiddenType, true, { spaceId }), + unauthorized: [ + createTestDefinitions(normalTypes, true, { spaceId }), + createTestDefinitions(hiddenType, false, { spaceId }), // validation for hidden type returns 404 Not Found before authZ check ].flat(), - superuser: createTestDefinitions(allTypes, false, { spaceId }), + authorized: createTestDefinitions(allTypes, false, { spaceId }), }; }; describe('_delete', () => { getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorized, superuser } = createTests(spaceId); + const { unauthorized, authorized } = createTests(spaceId); const _addTests = (user: TestUser, tests: DeleteTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; @@ -96,10 +95,9 @@ export default function ({ getService }: FtrProviderContext) { ].forEach((user) => { _addTests(user, unauthorized); }); - [users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => { + [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach((user) => { _addTests(user, authorized); }); - _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index 6d9c38ecca596..6ec1684c8aade 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -6,7 +6,6 @@ */ import { SPACES } from '../../common/lib/spaces'; -import { AUTHENTICATION } from '../../common/lib/authentication'; import { getTestScenarios, isUserAuthorizedAtSpace, @@ -33,16 +32,19 @@ const createTestCases = (currentSpace: string, crossSpaceSearch?: string[]) => { cases.pageBeyondTotal, cases.unknownSearchField, cases.filterWithNamespaceAgnosticType, - cases.filterWithDisallowedType, ]; + const badRequestTypes = [cases.filterWithDisallowedType]; const hiddenAndUnknownTypes = [ cases.hiddenType, cases.unknownType, cases.filterWithHiddenType, cases.filterWithUnknownType, ]; - const allTypes = normalTypes.concat(hiddenAndUnknownTypes); - return { normalTypes, hiddenAndUnknownTypes, allTypes }; + return { + normalTypes, + badRequestTypes, + hiddenAndUnknownTypes, + }; }; export default function ({ getService }: FtrProviderContext) { @@ -57,16 +59,6 @@ export default function ({ getService }: FtrProviderContext) { const explicitCrossSpace = createTestCases(spaceId, EACH_SPACE); const wildcardCrossSpace = createTestCases(spaceId, ['*']); - if (user.username === AUTHENTICATION.SUPERUSER.username) { - return { - currentSpace: createTestDefinitions(currentSpaceCases.allTypes, false, { user }), - crossSpace: [ - createTestDefinitions(explicitCrossSpace.allTypes, false, { user }), - createTestDefinitions(wildcardCrossSpace.allTypes, false, { user }), - ].flat(), - }; - } - const isAuthorizedExplicitCrossSpaces = EACH_SPACE.some( (s) => s !== spaceId && isUserAuthorizedAtSpace(user, s) ); @@ -84,7 +76,7 @@ export default function ({ getService }: FtrProviderContext) { ), ].flat() : createTestDefinitions( - explicitCrossSpace.allTypes, + [explicitCrossSpace.normalTypes, explicitCrossSpace.hiddenAndUnknownTypes].flat(), { statusCode: 200, reason: 'unauthorized' }, { user } ); @@ -98,27 +90,36 @@ export default function ({ getService }: FtrProviderContext) { ), ].flat() : createTestDefinitions( - wildcardCrossSpace.allTypes, + [wildcardCrossSpace.normalTypes, wildcardCrossSpace.hiddenAndUnknownTypes].flat(), { statusCode: 200, reason: 'unauthorized' }, { user } ); + const currentSpaceDefinitions = isUserAuthorizedAtSpace(user, spaceId) + ? [ + createTestDefinitions(currentSpaceCases.normalTypes, false, { user }), + createTestDefinitions( + currentSpaceCases.hiddenAndUnknownTypes, + { statusCode: 200, reason: 'unauthorized' }, + { user } + ), + ].flat() + : createTestDefinitions( + [currentSpaceCases.normalTypes, currentSpaceCases.hiddenAndUnknownTypes].flat(), + { statusCode: 200, reason: 'unauthorized' }, + { user } + ); return { - currentSpace: isUserAuthorizedAtSpace(user, spaceId) - ? [ - createTestDefinitions(currentSpaceCases.normalTypes, false, { - user, - }), - createTestDefinitions(currentSpaceCases.hiddenAndUnknownTypes, { - statusCode: 200, - reason: 'unauthorized', - }), - ].flat() - : createTestDefinitions(currentSpaceCases.allTypes, { - statusCode: 200, - reason: 'unauthorized', - }), - crossSpace: [...explicitCrossSpaceDefinitions, ...wildcardCrossSpaceDefinitions], + currentSpace: [ + currentSpaceDefinitions, + createTestDefinitions(currentSpaceCases.badRequestTypes, false, { user }), // validation for filter returns 400 Bad Request before authZ check + ].flat(), + crossSpace: [ + explicitCrossSpaceDefinitions, + wildcardCrossSpaceDefinitions, + createTestDefinitions(explicitCrossSpace.badRequestTypes, false, { user }), // validation for filter returns 400 Bad Request before authZ check + createTestDefinitions(wildcardCrossSpace.badRequestTypes, false, { user }), // validation for filter returns 400 Bad Request before authZ check + ].flat(), }; }; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts index e61d5c10c2dbb..a6f976ae69599 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts @@ -58,19 +58,18 @@ export default function ({ getService }: FtrProviderContext) { const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); // use singleRequest to reduce execution time and/or test combined cases return { - unauthorized: createTestDefinitions(allTypes, true), - authorized: [ - createTestDefinitions(normalTypes, false), - createTestDefinitions(hiddenType, true), + unauthorized: [ + createTestDefinitions(normalTypes, true), + createTestDefinitions(hiddenType, false), // validation for hidden type returns 404 Not Found before authZ check ].flat(), - superuser: createTestDefinitions(allTypes, false), + authorized: createTestDefinitions(allTypes, false), }; }; describe('_get', () => { getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorized, superuser } = createTests(spaceId); + const { unauthorized, authorized } = createTests(spaceId); const _addTests = (user: TestUser, tests: GetTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; @@ -85,10 +84,10 @@ export default function ({ getService }: FtrProviderContext) { users.readGlobally, users.allAtSpace, users.readAtSpace, + users.superuser, ].forEach((user) => { _addTests(user, authorized); }); - _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts index eecc2e39f608d..46ac9a7342e9a 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts @@ -49,19 +49,18 @@ export default function ({ getService }: FtrProviderContext) { const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); // use singleRequest to reduce execution time and/or test combined cases return { - unauthorized: createTestDefinitions(allTypes, true), - authorized: [ - createTestDefinitions(normalTypes, false), - createTestDefinitions(hiddenType, true), + unauthorized: [ + createTestDefinitions(normalTypes, true), + createTestDefinitions(hiddenType, false), // validation for hidden type returns 400 Bad Request before authZ check ].flat(), - superuser: createTestDefinitions(allTypes, false), + authorized: createTestDefinitions(allTypes, false), }; }; describe('_resolve', () => { getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorized, superuser } = createTests(spaceId); + const { unauthorized, authorized } = createTests(spaceId); const _addTests = (user: TestUser, tests: ResolveTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; @@ -76,10 +75,10 @@ export default function ({ getService }: FtrProviderContext) { users.readGlobally, users.allAtSpace, users.readAtSpace, + users.superuser, ].forEach((user) => { _addTests(user, authorized); }); - _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts index 1f5cbe892a5f5..cf71994e6eb68 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -59,19 +59,18 @@ export default function ({ getService }: FtrProviderContext) { const createTests = (spaceId: string) => { const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); return { - unauthorized: createTestDefinitions(allTypes, true), - authorized: [ - createTestDefinitions(normalTypes, false), - createTestDefinitions(hiddenType, true), + unauthorized: [ + createTestDefinitions(normalTypes, true), + createTestDefinitions(hiddenType, false), // validation for hidden type returns 404 Not Found before authZ check ].flat(), - superuser: createTestDefinitions(allTypes, false), + authorized: createTestDefinitions(allTypes, false), }; }; describe('_update', () => { getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorized, superuser } = createTests(spaceId); + const { unauthorized, authorized } = createTests(spaceId); const _addTests = (user: TestUser, tests: UpdateTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; @@ -86,10 +85,9 @@ export default function ({ getService }: FtrProviderContext) { ].forEach((user) => { _addTests(user, unauthorized); }); - [users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => { + [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach((user) => { _addTests(user, authorized); }); - _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_delete.ts index 84f0c048f1a28..848a2ff525c5b 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_delete.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_delete.ts @@ -48,12 +48,8 @@ const createTestCases = (spaceId: string) => [ { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - const es = getService('es'); - - const { addTests, createTestDefinitions } = bulkDeleteTestSuiteFactory(es, esArchiver, supertest); +export default function (context: FtrProviderContext) { + const { addTests, createTestDefinitions } = bulkDeleteTestSuiteFactory(context); const createTests = (spaceId: string) => { const testCases = createTestCases(spaceId); return createTestDefinitions(testCases, false, { spaceId }); diff --git a/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts b/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts index f95267a02ab3f..4719d0e5164a6 100644 --- a/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts +++ b/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts @@ -57,7 +57,7 @@ export function disableLegacyUrlAliasesTestSuiteFactory( expect(response.body).to.eql({ statusCode: 403, error: 'Forbidden', - message: `Unable to disable aliases for ${targetType}`, + message: `Unable to disable aliases: Unable to bulk_update ${targetType}`, }); } const esResponse = await es.get(