diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/index.ts b/packages/core/saved-objects/core-saved-objects-api-browser/index.ts index 9fc3e6a78c5c0..e78c56d76556c 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/index.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/index.ts @@ -22,4 +22,7 @@ export type { SavedObjectsBulkUpdateOptions, SavedObjectsBulkResolveResponse, SavedObjectsBulkCreateObject, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteResponseItem, + SavedObjectsBulkDeleteResponse, } from './src/apis'; 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 new file mode 100644 index 0000000000000..1e4b5d2268dea --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/bulk_delete.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 { SavedObjectError } from '@kbn/core-saved-objects-common'; + +/** @public */ +export interface SavedObjectsBulkDeleteOptions { + force?: boolean; +} + +/** @public */ +export interface SavedObjectsBulkDeleteResponseItem { + id: string; + type: string; + success: boolean; + error?: SavedObjectError; +} + +/** @public */ +export interface SavedObjectsBulkDeleteResponse { + statuses: SavedObjectsBulkDeleteResponseItem[]; +} diff --git a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/index.ts b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/index.ts index 4652facb972cc..afee0a01494e1 100644 --- a/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/index.ts +++ b/packages/core/saved-objects/core-saved-objects-api-browser/src/apis/index.ts @@ -19,3 +19,8 @@ export type { } from './find'; export type { ResolvedSimpleSavedObject } from './resolve'; export type { SavedObjectsUpdateOptions } from './update'; +export type { + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteResponseItem, + SavedObjectsBulkDeleteResponse, +} from './bulk_delete'; 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 123b5c81d4064..d222770a8579d 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 @@ -19,7 +19,10 @@ import type { SavedObjectsFindOptions, SavedObjectsUpdateOptions, SavedObjectsDeleteOptions, + SavedObjectsBulkDeleteResponse, + SavedObjectsBulkDeleteOptions, } from './apis'; + import type { SimpleSavedObject } from './simple_saved_object'; /** @@ -52,6 +55,17 @@ export interface SavedObjectsClientContract { */ delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; + /** + * Deletes multiple documents at once + * @param objects - an array of objects containing id, type + * @param options - optional force argument to force deletion of objects in a namespace other than the scoped client + * @returns The bulk delete result for the saved objects for the given types and ids. + */ + bulkDelete( + objects: SavedObjectTypeIdTuple[], + options?: SavedObjectsBulkDeleteOptions + ): Promise; + /** * Search for objects * 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 c86945c6acb5c..0739c9acab8f5 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 @@ -46,6 +46,8 @@ import type { SavedObjectsCollectMultiNamespaceReferencesResponse, SavedObjectsUpdateObjectsSpacesObject, SavedObjectsUpdateObjectsSpacesOptions, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, } from '@kbn/core-saved-objects-api-server'; import type { SavedObjectsType, @@ -2044,6 +2046,517 @@ describe('SavedObjectsRepository', () => { }); }); + describe('#bulkDelete', () => { + const obj1: SavedObjectsBulkDeleteObject = { + type: 'config', + id: '6.0.0-alpha1', + }; + const obj2: SavedObjectsBulkDeleteObject = { + type: 'index-pattern', + id: 'logstash-*', + }; + + const namespace = 'foo-namespace'; + + 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[], + { + method, + _index = expect.any(String), + getId = () => expect.any(String), + overrides = {}, + }: { + method: string; + _index?: string; + getId?: (type: string, id: string) => string; + overrides?: Record; + } + ) => { + const body = []; + for (const { type, id } of objects) { + body.push({ + [method]: { + _index, + _id: getId(type, id), + ...overrides, + }, + }); + } + + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }; + + const createBulkDeleteFailStatus = ({ + type, + id, + error, + }: { + type: string; + id: string; + error?: ExpectedErrorResult['error']; + }) => ({ + type, + id, + success: false, + error: error ?? createBadRequestError(), + }); + + const createBulkDeleteSuccessStatus = ({ type, id }: { type: string; id: string }) => ({ + type, + id, + success: true, + }); + + // mocks a combination of success, error results for hidden and unknown object object types. + const repositoryBulkDeleteError = async ( + obj: SavedObjectsBulkDeleteObject, + isBulkError: boolean, + expectedErrorResult: ExpectedErrorResult + ) => { + const objects = [obj1, obj, obj2]; + const mockedBulkDeleteResponse = getMockEsBulkDeleteResponse(objects); + if (isBulkError) { + mockGetBulkOperationError.mockReturnValueOnce(undefined); + mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload); + } + client.bulk.mockResponseOnce(mockedBulkDeleteResponse); + + const result = await savedObjectsRepository.bulkDelete(objects); + expect(client.bulk).toHaveBeenCalled(); + expect(result).toEqual({ + statuses: [ + createBulkDeleteSuccessStatus(obj1), + createBulkDeleteFailStatus({ ...obj, error: expectedErrorResult.error }), + createBulkDeleteSuccessStatus(obj2), + ], + }); + }; + + const expectClientCallArgsAction = ( + objects: TypeIdTuple[], + { + method, + _index = expect.any(String), + getId = () => expect.any(String), + overrides = {}, + }: { + method: string; + _index?: string; + getId?: (type: string, id: string) => string; + overrides?: Record; + } + ) => { + const body = []; + for (const { type, id } of objects) { + body.push({ + [method]: { + _index, + _id: getId(type, id), + ...overrides, + }, + }); + } + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }; + + const bulkDeleteMultiNamespaceError = async ( + [obj1, _obj, obj2]: SavedObjectsBulkDeleteObject[], + options: SavedObjectsBulkDeleteOptions | undefined, + mgetResponse: estypes.MgetResponse, + mgetOptions?: { statusCode?: number } + ) => { + const getId = (type: string, id: string) => `${options?.namespace}:${type}:${id}`; + // 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 }); + client.bulk.mockResponseOnce(bulkResponse); + + const result = await savedObjectsRepository.bulkDelete([obj1, _obj, obj2], options); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); + + expectClientCallArgsAction([obj1, obj2], { method: 'delete', getId }); + expect(result).toEqual({ + statuses: [ + createBulkDeleteSuccessStatus(obj1), + { ...expectErrorNotFound(_obj), success: false }, + createBulkDeleteSuccessStatus(obj2), + ], + }); + }; + + beforeEach(() => { + mockDeleteLegacyUrlAliases.mockClear(); + mockDeleteLegacyUrlAliases.mockResolvedValue(); + }); + + describe('client calls', () => { + it(`should use the ES bulk action by default`, async () => { + await repositoryBulkDeleteSuccess([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); + expect(client.bulk).toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalled(); + + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ body: { docs } }), + expect.anything() + ); + }); + + 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); + expect(client.bulk).toHaveBeenCalledTimes(0); + }); + + it(`formats the ES request`, async () => { + const getId = createNamespaceAwareGetId; + await repositoryBulkDeleteSuccess([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 }); + expectClientCallBulkDeleteArgsAction([obj1, _obj2], { method: 'delete', getId }); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + await repositoryBulkDeleteSuccess([obj1, obj2]); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); + }); + + 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); + 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 }); + 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]); + 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' }); + expectClientCallBulkDeleteArgsAction([obj1, obj2], { method: 'delete', getId }); + }); + + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type: string, id: string) => `${type}:${id}`; // not expecting namespace prefix; + const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; + + await repositoryBulkDeleteSuccess([_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]); + 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], + }, + }; + await repositoryBulkDeleteSuccess( + [testObject], + { namespace, force: true }, + internalOptions + ); + expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith( + expect.objectContaining({ + type: MULTI_NAMESPACE_TYPE, + id: testObject.id, + namespaces: [], + deleteBehavior: 'exclusive', + }) + ); + }); + + 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], + }, + }; + // specifically test against the current namespace + await repositoryBulkDeleteSuccess([testObject], { namespace }, internalOptions); + expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith( + expect.objectContaining({ + type: MULTI_NAMESPACE_TYPE, + id: testObject.id, + namespaces: [namespace], + deleteBehavior: 'inclusive', + }) + ); + }); + + it(`deletes legacy URL aliases for multi-namespace object types shared to many specific spaces`, async () => { + const testObject = { ...obj1, type: MULTI_NAMESPACE_TYPE }; + const initialTestObjectNamespaces = [namespace, 'bar-namespace']; + const internalOptions = { + mockMGetResponseWithObject: { + ...testObject, + initialNamespaces: initialTestObjectNamespaces, + }, + }; + // specifically test against named spaces ('*' is handled specifically, this assures we also take care of named spaces) + await repositoryBulkDeleteSuccess( + [testObject], + { namespace, force: true }, + internalOptions + ); + expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith( + expect.objectContaining({ + type: MULTI_NAMESPACE_TYPE, + id: testObject.id, + namespaces: initialTestObjectNamespaces, + deleteBehavior: 'inclusive', + }) + ); + }); + + it(`logs a message when deleteLegacyUrlAliases returns an error`, async () => { + const testObject = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: obj1.id }; + + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + getMockMgetResponse([testObject], namespace) + ) + ); + const mockedBulkResponse = getMockEsBulkDeleteResponse([testObject], { namespace }); + client.bulk.mockResolvedValueOnce(mockedBulkResponse); + + mockDeleteLegacyUrlAliases.mockRejectedValueOnce(new Error('Oh no!')); + + await savedObjectsRepository.bulkDelete([testObject], { namespace }); + + expect(client.mget).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + 'Unable to delete aliases when deleting an object: Oh no!' + ); + }); + }); + + 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 "*"')); + }); + + it(`throws an error when client bulk response is not defined`, async () => { + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + getMockMgetResponse([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( + 'Unexpected error in bulkDelete saved objects: bulkDeleteResponse is undefined' + ); + }); + + it(`returns an error for the object when the object's type is invalid`, async () => { + const unknownObjType = { ...obj1, type: 'unknownType' }; + await repositoryBulkDeleteError( + unknownObjType, + false, + expectErrorInvalidType(unknownObjType) + ); + }); + + it(`returns an error for an object when the object's type is hidden`, async () => { + const hiddenObject = { ...obj1, type: HIDDEN_TYPE }; + await repositoryBulkDeleteError(hiddenObject, false, expectErrorInvalidType(hiddenObject)); + }); + + 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); + await bulkDeleteMultiNamespaceError([obj1, notFoundObj, obj2], { namespace }, mgetResponse); + }); + + it(`returns an error when ES is unable to find the index during mget`, async () => { + const notFoundObj = { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false }; + await bulkDeleteMultiNamespaceError( + [obj1, notFoundObj, obj2], + { namespace }, + {} as estypes.MgetResponse, + { + statusCode: 404, + } + ); + }); + + it(`returns an error when the type is multi-namespace and the document exists, but not in this namespace`, async () => { + const obj = { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id: 'three', + namespace: 'bar-namespace', + }; + const mgetResponse = getMockMgetResponse([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'], + }, + }; + const result = await repositoryBulkDeleteSuccess( + [testObject], + { namespace }, + internalOptions + ); + 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' + ), + }) + ); + }); + + 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'], + }, + }; + const result = await repositoryBulkDeleteSuccess( + [testObject], + { namespace }, + internalOptions + ); + expect(result.statuses[0]).toStrictEqual( + createBulkDeleteFailStatus({ + ...testObject, + error: createBadRequestError("Unsupported saved object type: '*'"), + }) + ); + }); + }); + + describe('returns', () => { + it(`returns early for empty objects argument`, async () => { + await savedObjectsRepository.bulkDelete([], { namespace }); + expect(client.bulk).toHaveBeenCalledTimes(0); + }); + + it(`formats the ES response`, async () => { + const response = await repositoryBulkDeleteSuccess([obj1, obj2], { namespace }); + expect(response).toEqual({ + statuses: [obj1, obj2].map(createBulkDeleteSuccessStatus), + }); + }); + + it(`handles a mix of successful deletes and errors`, async () => { + const notFoundObj = { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false }; + await bulkDeleteMultiNamespaceError( + [obj1, notFoundObj, obj2], + { namespace }, + {} as estypes.MgetResponse, + { statusCode: 404 } + ); + }); + }); + }); + describe('#checkConflicts', () => { const obj1 = { type: 'dashboard', id: 'one' }; const obj2 = { type: 'dashboard', id: 'two' }; 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 961b44a1cd688..5569141c7fa0e 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 @@ -54,6 +54,9 @@ import type { SavedObjectsClosePointInTimeOptions, SavedObjectsCreatePointInTimeFinderOptions, SavedObjectsFindOptions, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteResponse, } from '@kbn/core-saved-objects-api-server'; import type { SavedObjectSanitizedDoc, @@ -83,6 +86,7 @@ import { type IndexMapping, type IKibanaMigrator, } 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 { getSearchDsl } from './search_dsl'; @@ -109,6 +113,16 @@ import { PreflightCheckForCreateObject, } from './preflight_check_for_create'; import { deleteLegacyUrlAliases } from './legacy_url_aliases'; +import type { + BulkDeleteParams, + ExpectedBulkDeleteResult, + BulkDeleteItemErrorResult, + NewBulkItemResponse, + BulkDeleteExpectedBulkGetResult, + PreflightCheckForBulkDeleteParams, + ExpectedBulkDeleteMultiNamespaceDocsParams, + ObjectToDeleteAliasesFor, +} from './repository_bulk_delete_internal_types'; // 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. @@ -127,6 +141,7 @@ export interface SavedObjectsRepositoryOptions { export const DEFAULT_REFRESH_SETTING = 'wait_for'; export const DEFAULT_RETRY_COUNT = 3; +const MAX_CONCURRENT_ALIAS_DELETIONS = 10; /** * @internal */ @@ -676,7 +691,6 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { refresh = DEFAULT_REFRESH_SETTING, force } = options; const namespace = normalizeNamespace(options.namespace); @@ -762,6 +776,286 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { ); } + /** + * Performs initial checks on object type validity and flags multi-namespace objects for preflight checks by adding an `esRequestIndex` + * @param objects SavedObjectsBulkDeleteObject[] + * @returns array BulkDeleteExpectedBulkGetResult[] + * @internal + */ + private presortObjectsByNamespaceType(objects: SavedObjectsBulkDeleteObject[]) { + let bulkGetRequestIndexCounter = 0; + return objects.map((object) => { + const { type, id } = object; + if (!this._allowedTypes.includes(type)) { + return { + tag: 'Left', + value: { + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), + }, + }; + } + const requiresNamespacesCheck = this._registry.isMultiNamespace(type); + return { + tag: 'Right', + value: { + type, + id, + ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + }, + }; + }); + } + + /** + * Fetch multi-namespace saved objects + * @returns MgetResponse + * @notes multi-namespace objects shared to more than one space require special handling. We fetch these docs to retrieve their namespaces. + * @internal + */ + private async preflightCheckForBulkDelete(params: PreflightCheckForBulkDeleteParams) { + const { expectedBulkGetResults, namespace } = params; + const bulkGetMultiNamespaceDocs = expectedBulkGetResults + .filter(isRight) + .filter(({ value }) => value.esRequestIndex !== undefined) + .map(({ value: { type, id } }) => ({ + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + _source: ['type', 'namespaces'], + })); + + const bulkGetMultiNamespaceDocsResponse = bulkGetMultiNamespaceDocs.length + ? await this.client.mget( + { body: { docs: bulkGetMultiNamespaceDocs } }, + { ignore: [404], meta: true } + ) + : undefined; + // fail fast if we can't verify a 404 response is from Elasticsearch + if ( + bulkGetMultiNamespaceDocsResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetMultiNamespaceDocsResponse.statusCode, + headers: bulkGetMultiNamespaceDocsResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } + return bulkGetMultiNamespaceDocsResponse; + } + + /** + * @returns array of objects sorted by expected delete success or failure result + * @internal + */ + private getExpectedBulkDeleteMultiNamespaceDocsResults( + params: ExpectedBulkDeleteMultiNamespaceDocsParams + ): ExpectedBulkDeleteResult[] { + const { expectedBulkGetResults, multiNamespaceDocsResponse, namespace, force } = params; + let indexCounter = 0; + const expectedBulkDeleteMultiNamespaceDocsResults = + expectedBulkGetResults.map((expectedBulkGetResult) => { + if (isLeft(expectedBulkGetResult)) { + return { ...expectedBulkGetResult }; + } + const { esRequestIndex: esBulkGetRequestIndex, id, type } = expectedBulkGetResult.value; + + let namespaces; + + if (esBulkGetRequestIndex !== undefined) { + const indexFound = multiNamespaceDocsResponse?.statusCode !== 404; + + const actualResult = indexFound + ? multiNamespaceDocsResponse?.body.docs[esBulkGetRequestIndex] + : undefined; + + const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; + + // return an error if the doc isn't found at all or the doc doesn't exist in the namespaces + if (!docFound) { + return { + tag: 'Left', + value: { + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + }, + }; + } + // the following check should be redundant since we're retrieving the docs from elasticsearch but we check just to make sure + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + if (!this.rawDocExistsInNamespace(actualResult, namespace)) { + return { + tag: 'Left', + value: { + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + }, + }; + } + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + namespaces = actualResult!._source.namespaces ?? [ + SavedObjectsUtils.namespaceIdToString(namespace), + ]; + const useForce = force && force === true ? true : false; + // 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 { + tag: 'Left', + value: { + success: false, + id, + type, + error: errorContent( + SavedObjectsErrorHelpers.createBadRequestError( + `Unable to delete saved object that exists in multiple namespaces, use the "force" option to delete it anyway` + ) + ), + }, + }; + } + } + // contains all objects that passed initial preflight checks, including single namespace objects that skipped the mget call + // single namespace objects will have namespaces:undefined + const expectedResult = { + type, + id, + namespaces, + esRequestIndex: indexCounter++, + }; + + return { tag: 'Right', value: expectedResult }; + }); + return expectedBulkDeleteMultiNamespaceDocsResults; + } + + /** + * {@inheritDoc ISavedObjectsRepository.bulkDelete} + */ + async bulkDelete( + objects: SavedObjectsBulkDeleteObject[], + options: SavedObjectsBulkDeleteOptions = {} + ): Promise { + const { refresh = DEFAULT_REFRESH_SETTING, force } = options; + const namespace = normalizeNamespace(options.namespace); + const expectedBulkGetResults = this.presortObjectsByNamespaceType(objects); + const multiNamespaceDocsResponse = await this.preflightCheckForBulkDelete({ + expectedBulkGetResults, + namespace, + }); + const bulkDeleteParams: BulkDeleteParams[] = []; + + const expectedBulkDeleteMultiNamespaceDocsResults = + this.getExpectedBulkDeleteMultiNamespaceDocsResults({ + expectedBulkGetResults, + multiNamespaceDocsResponse, + 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), + }, + }); + } + }); + + const bulkDeleteResponse = bulkDeleteParams.length + ? await this.client.bulk({ + refresh, + body: bulkDeleteParams, + require_alias: true, + }) + : undefined; + + // extracted to ensure consistency in the error results returned + let errorResult: BulkDeleteItemErrorResult; + const objectsToDeleteAliasesFor: ObjectToDeleteAliasesFor[] = []; + + const savedObjects = expectedBulkDeleteMultiNamespaceDocsResults.map((expectedResult) => { + if (isLeft(expectedResult)) { + return { ...expectedResult.value, success: false }; + } + const { + type, + id, + namespaces, + esRequestIndex: esBulkDeleteRequestIndex, + } = expectedResult.value; + // we assume this wouldn't happen but is needed to ensure type consistency + if (bulkDeleteResponse === undefined) { + throw new Error( + `Unexpected error in bulkDelete saved objects: bulkDeleteResponse is undefined` + ); + } + const rawResponse = Object.values( + bulkDeleteResponse.items[esBulkDeleteRequestIndex] + )[0] as NewBulkItemResponse; + + const error = getBulkOperationError(type, id, rawResponse); + if (error) { + errorResult = { success: false, type, id, error }; + return errorResult; + } + if (rawResponse.result === 'not_found') { + errorResult = { + success: false, + type, + id, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + }; + return errorResult; + } + + if (rawResponse.result === 'deleted') { + // `namespaces` should only exist in the expectedResult.value if the type is multi-namespace. + if (namespaces) { + objectsToDeleteAliasesFor.push({ + type, + id, + ...(namespaces.includes(ALL_NAMESPACES_STRING) + ? { namespaces: [], deleteBehavior: 'exclusive' } + : { namespaces, deleteBehavior: 'inclusive' }), + }); + } + } + const successfulResult = { + success: true, + id, + type, + }; + return successfulResult; + }); + + // Delete aliases if necessary, ensuring we don't have too many concurrent operations running. + const mapper = async ({ type, id, namespaces, deleteBehavior }: ObjectToDeleteAliasesFor) => + await deleteLegacyUrlAliases({ + mappings: this._mappings, + registry: this._registry, + client: this.client, + getIndexForType: this.getIndexForType.bind(this), + type, + id, + namespaces, + deleteBehavior, + }).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] }; + } + /** * {@inheritDoc ISavedObjectsRepository.deleteByNamespace} */ 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 new file mode 100644 index 0000000000000..93d4354d8d7e8 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_bulk_delete_internal_types.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Payload } from '@hapi/boom'; +import { + 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'; + +/** + * @internal + */ +export interface PreflightCheckForBulkDeleteParams { + expectedBulkGetResults: BulkDeleteExpectedBulkGetResult[]; + namespace?: string; +} + +/** + * @internal + */ +export interface ExpectedBulkDeleteMultiNamespaceDocsParams { + // contains the type and id of all objects to delete + expectedBulkGetResults: BulkDeleteExpectedBulkGetResult[]; + // subset of multi-namespace only expectedBulkGetResults + multiNamespaceDocsResponse: TransportResult, unknown> | undefined; + // current namespace in which the bulkDelete call is made + namespace: string | undefined; + // optional parameter used to force delete multinamespace objects that exist in more than the current space + force?: boolean; +} +/** + * @internal + */ +export interface BulkDeleteParams { + delete: Omit; +} + +/** + * @internal + */ +export type ExpectedBulkDeleteResult = Either< + { type: string; id: string; error: Payload }, + { + type: string; + id: string; + namespaces: string[]; + esRequestIndex: number; + } +>; + +/** + * @internal + */ +export interface BulkDeleteItemErrorResult { + success: boolean; + type: string; + id: string; + error: Payload; +} + +/** + * @internal + */ +export type NewBulkItemResponse = BulkResponseItem & { error: ErrorCause & { index: string } }; + +/** + * @internal + * @note Contains all documents for bulk delete, regardless of namespace type + */ +export type BulkDeleteExpectedBulkGetResult = Either< + { type: string; id: string; error: Payload }, + { type: string; id: string; version?: string; esRequestIndex?: number } +>; + +export type ObjectToDeleteAliasesFor = Pick< + DeleteLegacyUrlAliasesParams, + 'type' | 'id' | 'namespaces' | 'deleteBehavior' +>; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/repository.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/repository.mock.ts index 2cdfcf1710ad4..dc6c06c0c828d 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/repository.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/repository.mock.ts @@ -16,6 +16,7 @@ const createRepositoryMock = () => { create: jest.fn(), bulkCreate: jest.fn(), bulkUpdate: jest.fn(), + bulkDelete: jest.fn(), delete: jest.fn(), bulkGet: jest.fn(), find: jest.fn(), diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/saved_objects_client.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/saved_objects_client.test.ts index 5829f34a6ba79..38d4e75a0c528 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/saved_objects_client.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/saved_objects_client.test.ts @@ -23,6 +23,8 @@ import type { SavedObjectsFindOptions, SavedObjectsUpdateObjectsSpacesObject, SavedObjectsUpdateObjectsSpacesOptions, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteObject, } from '@kbn/core-saved-objects-api-server'; import { SavedObjectsClient } from './saved_objects_client'; import { repositoryMock, savedObjectsPointInTimeFinderMock } from './mocks'; @@ -119,6 +121,22 @@ describe('SavedObjectsClient', () => { }); }); + test(`#bulkDelete`, async () => { + const returnValue: any = Symbol(); + mockRepository.bulkDelete.mockResolvedValueOnce(returnValue); + const client = new SavedObjectsClient(mockRepository); + + const objects: SavedObjectsBulkDeleteObject[] = [ + { type: 'foo', id: '1' }, + { type: 'bar', id: '2' }, + ]; + const options: SavedObjectsBulkDeleteOptions = { namespace: 'ns-1', refresh: true }; + const result = await client.bulkDelete(objects, options); + + expect(mockRepository.bulkDelete).toHaveBeenCalledWith(objects, options); + expect(result).toBe(returnValue); + }); + test(`#delete`, async () => { const returnValue: any = Symbol(); mockRepository.delete.mockResolvedValueOnce(returnValue); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/saved_objects_client.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/saved_objects_client.ts index 7c2b3a205b76d..50f78f09dd684 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/saved_objects_client.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/saved_objects_client.ts @@ -39,6 +39,9 @@ import type { SavedObjectsClosePointInTimeOptions, SavedObjectsCreatePointInTimeFinderOptions, SavedObjectsFindOptions, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteResponse, } from '@kbn/core-saved-objects-api-server'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server'; @@ -83,6 +86,14 @@ export class SavedObjectsClient implements SavedObjectsClientContract { return await this._repository.delete(type, id, options); } + /** {@inheritDoc SavedObjectsClientContract.bulkDelete} */ + async bulkDelete( + objects: SavedObjectsBulkDeleteObject[], + options: SavedObjectsBulkDeleteOptions = {} + ): Promise { + return await this._repository.bulkDelete(objects, options); + } + /** {@inheritDoc SavedObjectsClientContract.find} */ async find( options: SavedObjectsFindOptions diff --git a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/repository.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/repository.mock.ts index d950b041d2432..168f4c8de6b59 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/repository.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/repository.mock.ts @@ -15,6 +15,7 @@ const create = () => { create: jest.fn(), bulkCreate: jest.fn(), bulkUpdate: jest.fn(), + bulkDelete: jest.fn(), delete: jest.fn(), bulkGet: jest.fn(), find: jest.fn(), diff --git a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_client.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_client.mock.ts index 75ee540cb7d8a..523e5003e650f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_client.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-mocks/src/saved_objects_client.mock.ts @@ -18,6 +18,7 @@ const create = () => { checkConflicts: jest.fn(), bulkUpdate: jest.fn(), delete: jest.fn(), + bulkDelete: jest.fn(), bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), 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 1c9688a236920..fdaa5685fbde0 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 @@ -52,4 +52,8 @@ export type { SavedObjectsCreatePointInTimeFinderOptions, SavedObjectsFindOptions, SavedObjectsPointInTimeFinderClient, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteStatus, + SavedObjectsBulkDeleteResponse, } from './src/apis'; 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 new file mode 100644 index 0000000000000..76d490925c580 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_delete.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { SavedObjectError } from '@kbn/core-saved-objects-common'; +import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base'; + +/** + * + * @public + */ +export interface SavedObjectsBulkDeleteObject { + type: string; + id: string; +} + +/** + * @public + */ +export interface SavedObjectsBulkDeleteOptions extends SavedObjectsBaseOptions { + /** The Elasticsearch Refresh setting for this operation */ + refresh?: MutatingOperationRefreshSetting; + /** + * Force deletion of all objects that exists in multiple namespaces, applied to all objects. + */ + force?: boolean; +} + +/** + * @public + */ +export interface SavedObjectsBulkDeleteStatus { + id: string; + type: string; + /** The status of deleting the object: true for deleted, false for error */ + success: boolean; + /** Reason the object could not be deleted (success is false) */ + error?: SavedObjectError; +} + +/** + * @public + */ +export interface SavedObjectsBulkDeleteResponse { + statuses: SavedObjectsBulkDeleteStatus[]; +} diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/index.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/index.ts index 7dc8e7ab09fc6..d311f2316885d 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/index.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/index.ts @@ -72,3 +72,9 @@ export type { SavedObjectsUpdateObjectsSpacesOptions, SavedObjectsUpdateObjectsSpacesResponseObject, } from './update_objects_spaces'; +export type { + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteStatus, + SavedObjectsBulkDeleteResponse, +} from './bulk_delete'; 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 82808e4024c73..cfe1f4e6a146b 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 @@ -41,6 +41,9 @@ import type { SavedObjectsRemoveReferencesToResponse, SavedObjectsCollectMultiNamespaceReferencesOptions, SavedObjectsBulkResponse, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteResponse, } from './apis'; /** @@ -151,6 +154,16 @@ export interface SavedObjectsClientContract { */ delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; + /** + * Deletes multiple SavedObjects batched together as a single request + * + * @param objects + * @param options + */ + bulkDelete( + objects: SavedObjectsBulkDeleteObject[], + options?: SavedObjectsBulkDeleteOptions + ): Promise; /** * Find all SavedObjects matching the search query * 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 102afce9dd73d..d7d2ca57ae3a2 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 @@ -44,6 +44,9 @@ import type { SavedObjectsDeleteByNamespaceOptions, SavedObjectsIncrementCounterField, SavedObjectsIncrementCounterOptions, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteResponse, } from './apis'; /** @@ -105,6 +108,17 @@ export interface ISavedObjectsRepository { */ 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={}] + * @returns {promise} - { statuses: [{ id, type, success, error: { message } }] } + */ + bulkDelete( + objects: SavedObjectsBulkDeleteObject[], + options?: SavedObjectsBulkDeleteOptions + ): Promise; + /** * Deletes all objects from the provided namespace. * diff --git a/packages/core/saved-objects/core-saved-objects-browser-internal/src/saved_objects_client.test.ts b/packages/core/saved-objects/core-saved-objects-browser-internal/src/saved_objects_client.test.ts index 77391343cd033..6c2966ee9775f 100644 --- a/packages/core/saved-objects/core-saved-objects-browser-internal/src/saved_objects_client.test.ts +++ b/packages/core/saved-objects/core-saved-objects-browser-internal/src/saved_objects_client.test.ts @@ -308,6 +308,45 @@ describe('SavedObjectsClient', () => { }); }); + describe('#bulk_delete', () => { + const bulkDeleteDoc = { + id: 'AVwSwFxtcMV38qjDZoQg', + type: 'config', + }; + beforeEach(() => { + http.fetch.mockResolvedValue({ + statuses: [{ id: bulkDeleteDoc.id, type: bulkDeleteDoc.type, success: true }], + }); + }); + + test('deletes with an array of id, type and success status for deleted docs', async () => { + const response = savedObjectsClient.bulkDelete([bulkDeleteDoc]); + await expect(response).resolves.toHaveProperty('statuses'); + + const result = await response; + expect(result.statuses).toHaveLength(1); + expect(result.statuses[0]).toHaveProperty('success'); + }); + + test('makes HTTP call', async () => { + await savedObjectsClient.bulkDelete([bulkDeleteDoc]); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_bulk_delete", + Object { + "body": "[{\\"type\\":\\"config\\",\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\"}]", + "method": "POST", + "query": Object { + "force": false, + }, + }, + ], + ] + `); + }); + }); + describe('#update', () => { const attributes = { foo: 'Foo', bar: 'Bar' }; const options = { version: '1' }; diff --git a/packages/core/saved-objects/core-saved-objects-browser-internal/src/saved_objects_client.ts b/packages/core/saved-objects/core-saved-objects-browser-internal/src/saved_objects_client.ts index 3a16030983fa9..dd2feed58123f 100644 --- a/packages/core/saved-objects/core-saved-objects-browser-internal/src/saved_objects_client.ts +++ b/packages/core/saved-objects/core-saved-objects-browser-internal/src/saved_objects_client.ts @@ -11,9 +11,11 @@ import type { HttpSetup, HttpFetchOptions } from '@kbn/core-http-browser'; import type { SavedObject, SavedObjectTypeIdTuple } from '@kbn/core-saved-objects-common'; import type { SavedObjectsBulkResolveResponse as SavedObjectsBulkResolveResponseServer, + SavedObjectsBulkDeleteResponse as SavedObjectsBulkDeleteResponseServer, SavedObjectsClientContract as SavedObjectsApi, SavedObjectsFindResponse as SavedObjectsFindResponseServer, SavedObjectsResolveResponse, + SavedObjectsBulkDeleteOptions, } from '@kbn/core-saved-objects-api-server'; import type { SavedObjectsClientContract, @@ -28,6 +30,7 @@ import type { SavedObjectsBulkCreateOptions, SavedObjectsBulkCreateObject, SimpleSavedObject, + SavedObjectsBulkDeleteResponse, } from '@kbn/core-saved-objects-api-browser'; import { SimpleSavedObjectImpl } from './simple_saved_object'; @@ -255,6 +258,31 @@ export class SavedObjectsClient implements SavedObjectsClientContract { return this.savedObjectsFetch(this.getPath([type, id]), { method: 'DELETE', query }); }; + public bulkDelete = async ( + objects: SavedObjectTypeIdTuple[], + options?: SavedObjectsBulkDeleteOptions + ): Promise => { + const filteredObjects = objects.map(({ type, id }) => ({ type, id })); + const queryOptions = { force: !!options?.force }; + const response = await this.performBulkDelete(filteredObjects, queryOptions); + return { + statuses: response.statuses, + }; + }; + + private async performBulkDelete( + objects: SavedObjectTypeIdTuple[], + queryOptions: { force: boolean } + ) { + const path = this.getPath(['_bulk_delete']); + const request: Promise = this.savedObjectsFetch(path, { + method: 'POST', + body: JSON.stringify(objects), + query: queryOptions, + }); + return request; + } + public find = ( options: SavedObjectsFindOptions ): Promise> => { diff --git a/packages/core/saved-objects/core-saved-objects-browser-mocks/src/saved_objects_service.mock.ts b/packages/core/saved-objects/core-saved-objects-browser-mocks/src/saved_objects_service.mock.ts index 0caa572238807..2239b94d7e2eb 100644 --- a/packages/core/saved-objects/core-saved-objects-browser-mocks/src/saved_objects_service.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-browser-mocks/src/saved_objects_service.mock.ts @@ -19,6 +19,7 @@ const createStartContractMock = () => { bulkCreate: jest.fn(), bulkResolve: jest.fn(), bulkUpdate: jest.fn(), + bulkDelete: jest.fn(), delete: jest.fn(), bulkGet: jest.fn(), find: jest.fn(), diff --git a/packages/core/saved-objects/core-saved-objects-server-internal/index.ts b/packages/core/saved-objects/core-saved-objects-server-internal/index.ts index caeb029e037f7..f7d6fa7918031 100644 --- a/packages/core/saved-objects/core-saved-objects-server-internal/index.ts +++ b/packages/core/saved-objects/core-saved-objects-server-internal/index.ts @@ -22,6 +22,7 @@ export { registerBulkCreateRoute } from './src/routes/bulk_create'; export { registerBulkGetRoute } from './src/routes/bulk_get'; export { registerBulkResolveRoute } from './src/routes/bulk_resolve'; export { registerBulkUpdateRoute } from './src/routes/bulk_update'; +export { registerBulkDeleteRoute } from './src/routes/bulk_delete'; export { registerCreateRoute } from './src/routes/create'; export { registerDeleteRoute } from './src/routes/delete'; export { registerExportRoute } from './src/routes/export'; diff --git a/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/bulk_delete.ts b/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/bulk_delete.ts new file mode 100644 index 0000000000000..f435eadebd066 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/bulk_delete.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 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 { schema } from '@kbn/config-schema'; +import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal'; +import type { InternalSavedObjectRouter } from '../internal_types'; +import { catchAndReturnBoomErrors } from './utils'; + +interface RouteDependencies { + coreUsageData: InternalCoreUsageDataSetup; +} + +export const registerBulkDeleteRoute = ( + router: InternalSavedObjectRouter, + { coreUsageData }: RouteDependencies +) => { + router.post( + { + path: '/_bulk_delete', + validate: { + body: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + query: schema.object({ + force: schema.maybe(schema.boolean()), + }), + }, + }, + catchAndReturnBoomErrors(async (context, req, res) => { + const { force } = req.query; + const usageStatsClient = coreUsageData.getClient(); + usageStatsClient.incrementSavedObjectsBulkDelete({ request: req }).catch(() => {}); + + const { savedObjects } = await context.core; + + const statuses = await savedObjects.client.bulkDelete(req.body, { force }); + return res.ok({ body: statuses }); + }) + ); +}; diff --git a/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/index.ts b/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/index.ts index 528793e8539bd..89d5b41dd8885 100644 --- a/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/index.ts +++ b/packages/core/saved-objects/core-saved-objects-server-internal/src/routes/index.ts @@ -23,6 +23,7 @@ import { registerUpdateRoute } from './update'; import { registerBulkGetRoute } from './bulk_get'; import { registerBulkCreateRoute } from './bulk_create'; import { registerBulkUpdateRoute } from './bulk_update'; +import { registerBulkDeleteRoute } from './bulk_delete'; import { registerExportRoute } from './export'; import { registerImportRoute } from './import'; import { registerResolveImportErrorsRoute } from './resolve_import_errors'; @@ -62,6 +63,7 @@ export function registerRoutes({ registerBulkCreateRoute(router, { coreUsageData }); registerBulkResolveRoute(router, { coreUsageData }); registerBulkUpdateRoute(router, { coreUsageData }); + registerBulkDeleteRoute(router, { coreUsageData }); registerExportRoute(router, { config, coreUsageData }); registerImportRoute(router, { config, coreUsageData }); registerResolveImportErrorsRoute(router, { config, coreUsageData }); diff --git a/packages/core/usage-data/core-usage-data-base-server-internal/src/usage_stats_client.ts b/packages/core/usage-data/core-usage-data-base-server-internal/src/usage_stats_client.ts index 3a603ebfdf5f0..735a4ab261658 100644 --- a/packages/core/usage-data/core-usage-data-base-server-internal/src/usage_stats_client.ts +++ b/packages/core/usage-data/core-usage-data-base-server-internal/src/usage_stats_client.ts @@ -43,6 +43,8 @@ export interface ICoreUsageStatsClient { incrementSavedObjectsBulkUpdate(options: BaseIncrementOptions): Promise; + incrementSavedObjectsBulkDelete(options: BaseIncrementOptions): Promise; + incrementSavedObjectsCreate(options: BaseIncrementOptions): Promise; incrementSavedObjectsDelete(options: BaseIncrementOptions): Promise; diff --git a/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.test.ts b/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.test.ts index f00341fdad0a7..1b7d332743697 100644 --- a/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.test.ts +++ b/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.test.ts @@ -20,6 +20,7 @@ import { BULK_CREATE_STATS_PREFIX, BULK_GET_STATS_PREFIX, BULK_UPDATE_STATS_PREFIX, + BULK_DELETE_STATS_PREFIX, CREATE_STATS_PREFIX, DELETE_STATS_PREFIX, FIND_STATS_PREFIX, @@ -452,6 +453,81 @@ describe('CoreUsageStatsClient', () => { }); }); + describe('#incrementSavedObjectsBulkDelete', () => { + it('does not throw an error if repository incrementCounter operation fails', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); + + const request = httpServerMock.createKibanaRequest(); + await expect( + usageStatsClient.incrementSavedObjectsBulkDelete({ + request, + } as BaseIncrementOptions) + ).resolves.toBeUndefined(); + expect(repositoryMock.incrementCounter).toHaveBeenCalled(); + }); + + it('handles falsy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + const request = httpServerMock.createKibanaRequest(); + await usageStatsClient.incrementSavedObjectsBulkDelete({ + request, + } as BaseIncrementOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${BULK_DELETE_STATS_PREFIX}.total`, + `${BULK_DELETE_STATS_PREFIX}.namespace.default.total`, + `${BULK_DELETE_STATS_PREFIX}.namespace.default.kibanaRequest.no`, + ], + incrementOptions + ); + }); + + it('handles truthy options and the default namespace string appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(DEFAULT_NAMESPACE_STRING); + + const request = httpServerMock.createKibanaRequest({ headers: firstPartyRequestHeaders }); + await usageStatsClient.incrementSavedObjectsBulkDelete({ + request, + } as BaseIncrementOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${BULK_DELETE_STATS_PREFIX}.total`, + `${BULK_DELETE_STATS_PREFIX}.namespace.default.total`, + `${BULK_DELETE_STATS_PREFIX}.namespace.default.kibanaRequest.yes`, + ], + incrementOptions + ); + }); + + it('handles a non-default space appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup('foo'); + + const request = httpServerMock.createKibanaRequest(); + await usageStatsClient.incrementSavedObjectsBulkDelete({ + request, + } as BaseIncrementOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${BULK_DELETE_STATS_PREFIX}.total`, + `${BULK_DELETE_STATS_PREFIX}.namespace.custom.total`, + `${BULK_DELETE_STATS_PREFIX}.namespace.custom.kibanaRequest.no`, + ], + incrementOptions + ); + }); + }); + describe('#incrementSavedObjectsDelete', () => { it('does not throw an error if repository incrementCounter operation fails', async () => { const { usageStatsClient, repositoryMock } = setup(); diff --git a/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.ts b/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.ts index 49c7333bea772..3bafa2e20e562 100644 --- a/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.ts +++ b/packages/core/usage-data/core-usage-data-server-internal/src/core_usage_stats_client.ts @@ -25,6 +25,7 @@ export const BULK_CREATE_STATS_PREFIX = 'apiCalls.savedObjectsBulkCreate'; export const BULK_GET_STATS_PREFIX = 'apiCalls.savedObjectsBulkGet'; export const BULK_RESOLVE_STATS_PREFIX = 'apiCalls.savedObjectsBulkResolve'; export const BULK_UPDATE_STATS_PREFIX = 'apiCalls.savedObjectsBulkUpdate'; +export const BULK_DELETE_STATS_PREFIX = 'apiCalls.savedObjectsBulkDelete'; export const CREATE_STATS_PREFIX = 'apiCalls.savedObjectsCreate'; export const DELETE_STATS_PREFIX = 'apiCalls.savedObjectsDelete'; export const FIND_STATS_PREFIX = 'apiCalls.savedObjectsFind'; @@ -43,6 +44,7 @@ const ALL_COUNTER_FIELDS = [ ...getFieldsForCounter(BULK_GET_STATS_PREFIX), ...getFieldsForCounter(BULK_RESOLVE_STATS_PREFIX), ...getFieldsForCounter(BULK_UPDATE_STATS_PREFIX), + ...getFieldsForCounter(BULK_DELETE_STATS_PREFIX), ...getFieldsForCounter(CREATE_STATS_PREFIX), ...getFieldsForCounter(DELETE_STATS_PREFIX), ...getFieldsForCounter(FIND_STATS_PREFIX), @@ -114,6 +116,10 @@ export class CoreUsageStatsClient implements ICoreUsageStatsClient { await this.updateUsageStats([], BULK_UPDATE_STATS_PREFIX, options); } + public async incrementSavedObjectsBulkDelete(options: BaseIncrementOptions) { + await this.updateUsageStats([], BULK_DELETE_STATS_PREFIX, options); + } + public async incrementSavedObjectsCreate(options: BaseIncrementOptions) { await this.updateUsageStats([], CREATE_STATS_PREFIX, options); } diff --git a/packages/core/usage-data/core-usage-data-server-mocks/src/core_usage_stats_client.mock.ts b/packages/core/usage-data/core-usage-data-server-mocks/src/core_usage_stats_client.mock.ts index a6e76e33057dc..6da6da69f4962 100644 --- a/packages/core/usage-data/core-usage-data-server-mocks/src/core_usage_stats_client.mock.ts +++ b/packages/core/usage-data/core-usage-data-server-mocks/src/core_usage_stats_client.mock.ts @@ -15,6 +15,7 @@ const createUsageStatsClientMock = () => incrementSavedObjectsBulkGet: jest.fn().mockResolvedValue(null), incrementSavedObjectsBulkResolve: jest.fn().mockResolvedValue(null), incrementSavedObjectsBulkUpdate: jest.fn().mockResolvedValue(null), + incrementSavedObjectsBulkDelete: jest.fn().mockResolvedValue(null), incrementSavedObjectsCreate: jest.fn().mockResolvedValue(null), incrementSavedObjectsDelete: jest.fn().mockResolvedValue(null), incrementSavedObjectsFind: jest.fn().mockResolvedValue(null), diff --git a/packages/core/usage-data/core-usage-data-server/src/core_usage_stats.ts b/packages/core/usage-data/core-usage-data-server/src/core_usage_stats.ts index aef5b657fb6f7..279d5c68cd733 100644 --- a/packages/core/usage-data/core-usage-data-server/src/core_usage_stats.ts +++ b/packages/core/usage-data/core-usage-data-server/src/core_usage_stats.ts @@ -42,6 +42,13 @@ export interface CoreUsageStats { 'apiCalls.savedObjectsBulkUpdate.namespace.custom.total'?: number; 'apiCalls.savedObjectsBulkUpdate.namespace.custom.kibanaRequest.yes'?: number; 'apiCalls.savedObjectsBulkUpdate.namespace.custom.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsBulkDelete.total'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.default.total'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.custom.total'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.no'?: number; 'apiCalls.savedObjectsCreate.total'?: number; 'apiCalls.savedObjectsCreate.namespace.default.total'?: number; 'apiCalls.savedObjectsCreate.namespace.default.kibanaRequest.yes'?: number; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 67c29f0fb747c..6e72b1d2623cf 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -338,6 +338,9 @@ export type { SavedObjectsFindOptions, SavedObjectsFindOptionsReference, SavedObjectsPitParams, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteResponse, } from '@kbn/core-saved-objects-api-server'; export type { SavedObjectsServiceSetup, diff --git a/src/core/server/integration_tests/saved_objects/routes/bulk_delete.test.ts b/src/core/server/integration_tests/saved_objects/routes/bulk_delete.test.ts new file mode 100644 index 0000000000000..2536915f6f068 --- /dev/null +++ b/src/core/server/integration_tests/saved_objects/routes/bulk_delete.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 supertest from 'supertest'; +import { savedObjectsClientMock } from '../../../mocks'; +import type { ICoreUsageStatsClient } from '@kbn/core-usage-data-base-server-internal'; +import { + coreUsageStatsClientMock, + coreUsageDataServiceMock, +} from '@kbn/core-usage-data-server-mocks'; +import { setupServer } from './test_utils'; +import { + registerBulkDeleteRoute, + type InternalSavedObjectsRequestHandlerContext, +} from '@kbn/core-saved-objects-server-internal'; + +type SetupServerReturn = Awaited>; + +describe('POST /api/saved_objects/_bulk_delete', () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let handlerContext: SetupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + let coreUsageStatsClient: jest.Mocked; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + + savedObjectsClient.bulkDelete.mockResolvedValue({ + statuses: [], + }); + const router = + httpSetup.createRouter('/api/saved_objects/'); + coreUsageStatsClient = coreUsageStatsClientMock.create(); + coreUsageStatsClient.incrementSavedObjectsBulkDelete.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail + const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); + registerBulkDeleteRoute(router, { coreUsageData }); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response and records usage stats', async () => { + const clientResponse = { + statuses: [ + { + id: 'abc123', + type: 'index-pattern', + success: true, + }, + ], + }; + savedObjectsClient.bulkDelete.mockImplementation(() => Promise.resolve(clientResponse)); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_delete') + .send([ + { + id: 'abc123', + type: 'index-pattern', + }, + ]) + .expect(200); + + expect(result.body).toEqual(clientResponse); + expect(coreUsageStatsClient.incrementSavedObjectsBulkDelete).toHaveBeenCalledWith({ + request: expect.anything(), + }); + }); + + it('calls upon savedObjectClient.bulkDelete with query options', async () => { + const docs = [ + { + id: 'abc123', + type: 'index-pattern', + }, + ]; + + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_delete') + .send(docs) + .query({ force: true }) + .expect(200); + + expect(savedObjectsClient.bulkDelete).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkDelete).toHaveBeenCalledWith(docs, { force: true }); + }); +}); diff --git a/src/core/server/integration_tests/saved_objects/service/lib/repository_with_proxy.test.ts b/src/core/server/integration_tests/saved_objects/service/lib/repository_with_proxy.test.ts index 7581e5f3639a2..f0fdc609d8915 100644 --- a/src/core/server/integration_tests/saved_objects/service/lib/repository_with_proxy.test.ts +++ b/src/core/server/integration_tests/saved_objects/service/lib/repository_with_proxy.test.ts @@ -54,6 +54,17 @@ const registerSOTypes = (setup: InternalCoreSetup) => { }, namespaceType: 'single', }); + setup.savedObjects.registerType({ + name: 'my_bulk_delete_type', + hidden: false, + mappings: { + dynamic: false, + properties: { + title: { type: 'text' }, + }, + }, + namespaceType: 'single', + }); }; describe('404s from proxies', () => { @@ -124,6 +135,7 @@ describe('404s from proxies', () => { let repository: ISavedObjectsRepository; let myOtherType: SavedObject; const myOtherTypeDocs: SavedObject[] = []; + const myBulkDeleteTypeDocs: SavedObject[] = []; beforeAll(async () => { repository = start.savedObjects.createInternalRepository(); @@ -145,6 +157,19 @@ describe('404s from proxies', () => { overwrite: true, namespace: 'default', }); + + for (let i = 1; i < 11; i++) { + myBulkDeleteTypeDocs.push({ + type: 'my_bulk_delete_type', + id: `myOtherTypeId${i}`, + attributes: { title: `MyOtherTypeTitle${i}` }, + references: [], + }); + } + await repository.bulkCreate(myBulkDeleteTypeDocs, { + overwrite: true, + namespace: 'default', + }); }); beforeEach(() => { @@ -237,6 +262,18 @@ describe('404s from proxies', () => { ); }); + it('handles `bulkDelete` requests that are successful when the proxy passes through the product header', async () => { + const docsToDelete = myBulkDeleteTypeDocs; + const bulkDeleteDocs = docsToDelete.map((doc) => ({ + id: doc.id, + type: 'my_bulk_delete_type', + })); + + const docsFound = await repository.bulkDelete(bulkDeleteDocs, { force: false }); + expect(docsFound.statuses.length).toBeGreaterThan(0); + expect(docsFound.statuses[0].success).toBe(true); + }); + it('handles `bulkGet` requests that are successful when the proxy passes through the product header', async () => { const docsToGet = myOtherTypeDocs; const docsFound = await repository.bulkGet( diff --git a/src/core/server/integration_tests/saved_objects/service/lib/repository_with_proxy_utils.ts b/src/core/server/integration_tests/saved_objects/service/lib/repository_with_proxy_utils.ts index 6f1c2c523226c..251c7608b6299 100644 --- a/src/core/server/integration_tests/saved_objects/service/lib/repository_with_proxy_utils.ts +++ b/src/core/server/integration_tests/saved_objects/service/lib/repository_with_proxy_utils.ts @@ -122,7 +122,8 @@ export const declarePostMgetRoute = (hapiServer: Hapi.Server, hostname: string, if ( proxyInterrupt === 'bulkGetMyType' || proxyInterrupt === 'checkConficts' || - proxyInterrupt === 'internalBulkResolve' + proxyInterrupt === 'internalBulkResolve' || + proxyInterrupt === 'bulkDeleteMyDocs' ) { return proxyResponseHandler(h, hostname, port); } else { diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts index ce8e27f318cfd..28a921dd20162 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -822,6 +822,46 @@ export function getCoreUsageCollector( 'How many times this API has been called by a non-Kibana client in a custom space.', }, }, + 'apiCalls.savedObjectsBulkDelete.total': { + type: 'long', + _meta: { description: 'How many times this API has been called.' }, + }, + 'apiCalls.savedObjectsBulkDelete.namespace.default.total': { + type: 'long', + _meta: { description: 'How many times this API has been called in the Default space.' }, + }, + 'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.yes': { + type: 'long', + _meta: { + description: + 'How many times this API has been called by the Kibana client in the Default space.', + }, + }, + 'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.no': { + type: 'long', + _meta: { + description: + 'How many times this API has been called by a non-Kibana client in the Default space.', + }, + }, + 'apiCalls.savedObjectsBulkDelete.namespace.custom.total': { + type: 'long', + _meta: { description: 'How many times this API has been called in a custom space.' }, + }, + 'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.yes': { + type: 'long', + _meta: { + description: + 'How many times this API has been called by the Kibana client in a custom space.', + }, + }, + 'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.no': { + type: 'long', + _meta: { + description: + 'How many times this API has been called by a non-Kibana client in a custom space.', + }, + }, // Saved Objects Management APIs 'apiCalls.savedObjectsImport.total': { type: 'long', diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_data.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_data.ts index 36aac8ec0511a..2e5084f1b9ae5 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_data.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_data.ts @@ -38,6 +38,13 @@ export interface CoreUsageStats { 'apiCalls.savedObjectsBulkResolve.namespace.custom.total'?: number; 'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.yes'?: number; 'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsBulkDelete.total'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.default.total'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.custom.total'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.no'?: number; 'apiCalls.savedObjectsBulkUpdate.total'?: number; 'apiCalls.savedObjectsBulkUpdate.namespace.default.total'?: number; 'apiCalls.savedObjectsBulkUpdate.namespace.default.kibanaRequest.yes'?: number; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 5d962699345b0..30e476afd20cf 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7222,6 +7222,48 @@ "description": "How many times this API has been called by a non-Kibana client in a custom space." } }, + "apiCalls.savedObjectsBulkDelete.total": { + "type": "long", + "_meta": { + "description": "How many times this API has been called." + } + }, + "apiCalls.savedObjectsBulkDelete.namespace.default.total": { + "type": "long", + "_meta": { + "description": "How many times this API has been called in the Default space." + } + }, + "apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.yes": { + "type": "long", + "_meta": { + "description": "How many times this API has been called by the Kibana client in the Default space." + } + }, + "apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.no": { + "type": "long", + "_meta": { + "description": "How many times this API has been called by a non-Kibana client in the Default space." + } + }, + "apiCalls.savedObjectsBulkDelete.namespace.custom.total": { + "type": "long", + "_meta": { + "description": "How many times this API has been called in a custom space." + } + }, + "apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.yes": { + "type": "long", + "_meta": { + "description": "How many times this API has been called by the Kibana client in a custom space." + } + }, + "apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.no": { + "type": "long", + "_meta": { + "description": "How many times this API has been called by a non-Kibana client in a custom space." + } + }, "apiCalls.savedObjectsImport.total": { "type": "long", "_meta": { diff --git a/test/api_integration/apis/saved_objects/bulk_delete.ts b/test/api_integration/apis/saved_objects/bulk_delete.ts new file mode 100644 index 0000000000000..5b5292b97ddde --- /dev/null +++ b/test/api_integration/apis/saved_objects/bulk_delete.ts @@ -0,0 +1,114 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + describe('bulk_delete', () => { + before(async () => { + await kibanaServer.importExport.load( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + after(async () => { + await kibanaServer.importExport.unload( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ); + }); + + it('should return 200 with individual responses when deleting many docs', async () => + await supertest + .post(`/api/saved_objects/_bulk_delete`) + .send([ + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + }, + { + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + }, + ]) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + statuses: [ + { + success: true, + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + }, + { + success: true, + type: 'dashboard', + id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', + }, + ], + }); + })); + + it('should return generic 404 when deleting an unknown doc', async () => + await supertest + .post(`/api/saved_objects/_bulk_delete`) + .send([{ type: 'dashboard', id: 'not-a-real-id' }]) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + statuses: [ + { + error: { + error: 'Not Found', + message: 'Saved object [dashboard/not-a-real-id] not found', + statusCode: 404, + }, + id: 'not-a-real-id', + type: 'dashboard', + success: false, + }, + ], + }); + })); + + it('should return the result of deleting valid and invalid objects in the same request', async () => + await supertest + .post(`/api/saved_objects/_bulk_delete`) + .send([ + { type: 'visualization', id: 'not-a-real-vis-id' }, + { + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + }, + ]) + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + statuses: [ + { + error: { + error: 'Not Found', + message: 'Saved object [visualization/not-a-real-vis-id] not found', + statusCode: 404, + }, + id: 'not-a-real-vis-id', + type: 'visualization', + success: false, + }, + { + success: true, + type: 'index-pattern', + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + }, + ], + }); + })); + }); +} diff --git a/test/api_integration/apis/saved_objects/index.ts b/test/api_integration/apis/saved_objects/index.ts index 44ee3d8d7d76b..c981add4540b3 100644 --- a/test/api_integration/apis/saved_objects/index.ts +++ b/test/api_integration/apis/saved_objects/index.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('saved_objects', () => { loadTestFile(require.resolve('./bulk_create')); + loadTestFile(require.resolve('./bulk_delete')); loadTestFile(require.resolve('./bulk_get')); loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); 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 index 75dd3fdfe8dce..ab8b03840c819 100644 --- 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 @@ -870,6 +870,44 @@ describe('#delete', () => { }); }); +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 = { 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 index 0b9c81cc33334..e2fcfd2a6ef25 100644 --- 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 @@ -10,6 +10,8 @@ import type { SavedObject, SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, SavedObjectsBulkGetObject, SavedObjectsBulkResolveObject, SavedObjectsBulkResponse, @@ -166,6 +168,13 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon 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), diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts index 330eebc1602b4..3ba81321ca9ee 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts @@ -24,6 +24,7 @@ const writeOperations: string[] = [ 'update', 'bulk_update', 'delete', + 'bulk_delete', 'share_to_space', ]; const allOperations: string[] = [...readOperations, ...writeOperations]; diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index d9c82403a582a..1a8d7814e5a38 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -109,6 +109,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-1', 'update'), actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), actions.savedObject.get('all-savedObject-all-1', 'delete'), + actions.savedObject.get('all-savedObject-all-1', 'bulk_delete'), actions.savedObject.get('all-savedObject-all-1', 'share_to_space'), actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), actions.savedObject.get('all-savedObject-all-2', 'get'), @@ -120,6 +121,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-2', 'update'), actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), actions.savedObject.get('all-savedObject-all-2', 'delete'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_delete'), actions.savedObject.get('all-savedObject-all-2', 'share_to_space'), actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), actions.savedObject.get('all-savedObject-read-1', 'get'), @@ -148,6 +150,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'update'), actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-1', 'bulk_delete'), actions.savedObject.get('read-savedObject-all-1', 'share_to_space'), actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), @@ -159,6 +162,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'update'), actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_delete'), actions.savedObject.get('read-savedObject-all-2', 'share_to_space'), actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), @@ -301,6 +305,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-1', 'update'), actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), actions.savedObject.get('all-savedObject-all-1', 'delete'), + actions.savedObject.get('all-savedObject-all-1', 'bulk_delete'), actions.savedObject.get('all-savedObject-all-1', 'share_to_space'), actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), actions.savedObject.get('all-savedObject-all-2', 'get'), @@ -312,6 +317,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-2', 'update'), actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), actions.savedObject.get('all-savedObject-all-2', 'delete'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_delete'), actions.savedObject.get('all-savedObject-all-2', 'share_to_space'), actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), actions.savedObject.get('all-savedObject-read-1', 'get'), @@ -339,6 +345,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'update'), actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-1', 'bulk_delete'), actions.savedObject.get('read-savedObject-all-1', 'share_to_space'), actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), @@ -350,6 +357,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'update'), actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_delete'), actions.savedObject.get('read-savedObject-all-2', 'share_to_space'), actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), @@ -427,6 +435,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'update'), actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-1', 'bulk_delete'), actions.savedObject.get('read-savedObject-all-1', 'share_to_space'), actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), @@ -438,6 +447,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'update'), actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_delete'), actions.savedObject.get('read-savedObject-all-2', 'share_to_space'), actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), @@ -732,6 +742,7 @@ describe('reserved', () => { actions.savedObject.get('savedObject-all-1', 'update'), actions.savedObject.get('savedObject-all-1', 'bulk_update'), actions.savedObject.get('savedObject-all-1', 'delete'), + actions.savedObject.get('savedObject-all-1', 'bulk_delete'), actions.savedObject.get('savedObject-all-1', 'share_to_space'), actions.savedObject.get('savedObject-all-2', 'bulk_get'), actions.savedObject.get('savedObject-all-2', 'get'), @@ -743,6 +754,7 @@ describe('reserved', () => { actions.savedObject.get('savedObject-all-2', 'update'), actions.savedObject.get('savedObject-all-2', 'bulk_update'), actions.savedObject.get('savedObject-all-2', 'delete'), + actions.savedObject.get('savedObject-all-2', 'bulk_delete'), actions.savedObject.get('savedObject-all-2', 'share_to_space'), actions.savedObject.get('savedObject-read-1', 'bulk_get'), actions.savedObject.get('savedObject-read-1', 'get'), @@ -862,6 +874,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -993,6 +1006,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1015,6 +1029,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1044,6 +1059,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1081,6 +1097,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1104,6 +1121,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1127,6 +1145,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1149,6 +1168,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1227,6 +1247,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1249,6 +1270,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1278,6 +1300,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1384,6 +1407,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1406,6 +1430,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1455,6 +1480,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1484,6 +1510,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1567,6 +1594,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1589,6 +1617,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1709,6 +1738,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1738,6 +1768,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1775,6 +1806,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1798,6 +1830,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1821,6 +1854,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1843,6 +1877,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1941,6 +1976,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -1970,6 +2006,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -2007,6 +2044,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -2030,6 +2068,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -2053,6 +2092,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -2075,6 +2115,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -2173,6 +2214,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), @@ -2184,6 +2226,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'update'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -2219,6 +2262,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), @@ -2230,6 +2274,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'update'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -2273,6 +2318,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), @@ -2284,6 +2330,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'update'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -2313,6 +2360,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), @@ -2324,6 +2372,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'update'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -2353,6 +2402,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), @@ -2364,6 +2414,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'update'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), @@ -2392,6 +2443,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'), actions.savedObject.get('all-licensed-sub-feature-type', 'get'), @@ -2403,6 +2455,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-licensed-sub-feature-type', 'update'), actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-licensed-sub-feature-type', 'delete'), + actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'), actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), 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 index 52702c014f0cb..bce6786f06775 100644 --- 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 @@ -591,6 +591,67 @@ describe('#bulkUpdate', () => { }); }); +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' }); 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 index db0645992b2f7..b8e253b7f3160 100644 --- 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 @@ -9,6 +9,9 @@ import type { SavedObjectReferenceWithContext, SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteResponse, SavedObjectsBulkGetObject, SavedObjectsBulkResolveObject, SavedObjectsBulkUpdateObject, @@ -224,6 +227,48 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra 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 && 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 index 0060039f6f2ca..70a8628246b71 100644 --- 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 @@ -586,6 +586,41 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); + 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(); 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 index a7ef2dae5b386..52ca1f2604e88 100644 --- 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 @@ -12,6 +12,8 @@ import type { SavedObject, SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, SavedObjectsBulkGetObject, SavedObjectsBulkResolveObject, SavedObjectsBulkUpdateObject, @@ -139,6 +141,17 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } + 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 { 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 new file mode 100644 index 0000000000000..d0c90ccc3c255 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_delete.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 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'; + +export interface BulkDeleteTestDefinition extends TestDefinition { + request: { type: string; id: string; force?: boolean }; + force?: boolean; +} +export type BulkDeleteTestSuite = TestSuite; + +export interface BulkDeleteTestCase extends TestCase { + force?: boolean; + failure?: 400 | 403 | 404; +} + +const ALIAS_DELETE_INCLUSIVE = Object.freeze({ + type: 'resolvetype', + id: 'alias-match-newid', +}); // exists in three specific spaces; deleting this should also delete the aliases that target it in the default space and space_1 +const ALIAS_DELETE_EXCLUSIVE = Object.freeze({ + type: 'resolvetype', + id: 'all_spaces', +}); // exists in all spaces; deleting this should also delete the aliases that target it in the default space and space_1 +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES: Record = Object.freeze({ + ...CASES, + ALIAS_DELETE_INCLUSIVE, + ALIAS_DELETE_EXCLUSIVE, + DOES_NOT_EXIST, +}); + +/** + * Test cases have additional properties that we don't want to send in HTTP Requests + */ +const createRequest = ({ type, id, force }: BulkDeleteTestCase) => ({ type, id, force }); + +export function bulkDeleteTestSuiteFactory(es: Client, esArchiver: any, supertest: SuperTest) { + const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_delete'); + const expectResponseBody = + (testCase: BulkDeleteTestCase, statusCode: 200 | 403, user?: TestUser): ExpectResponseBody => + async (response: Record) => { + if (statusCode === 403) { + await expectSavedObjectForbidden(testCase.type)(response); + } else { + // permitted + const statuses = response.body.statuses; + expect(statuses).length([testCase].length); + for (let i = 0; i < statuses.length; i++) { + const object = statuses[i]; + expect(object).to.have.keys(['id', 'type', 'success']); + if (testCase.failure) { + const { type, id } = testCase; + expect(object.type).to.eql(type); + expect(object.id).to.eql(id); + await expectResponses.permitted(object, testCase); + } else { + await es.indices.refresh({ index: '.kibana' }); // alias deletion uses refresh: false, so we need to manually refresh the index before searching + const searchResponse = await es.search({ + index: '.kibana', + body: { + size: 0, + query: { terms: { type: ['legacy-url-alias'] } }, + track_total_hits: true, + }, + }); + + const expectAliasWasDeleted = !![ALIAS_DELETE_INCLUSIVE, ALIAS_DELETE_EXCLUSIVE].find( + ({ type, id }) => testCase.type === type && testCase.id === id + ); + // Eight aliases exist and they are all deleted in the bulk operation. + // The delete behavior for multinamespace objects shared to more than one space when using force is to delete the object from all the spaces it is shared to. + expect((searchResponse.hits.total as SearchTotalHits).value).to.eql( + expectAliasWasDeleted ? 6 : 8 + ); + } + } + } + }; + + const createTestDefinitions = ( + testCases: BulkDeleteTestCase | BulkDeleteTestCase[], + forbidden: boolean, + options?: { + spaceId?: string; + responseBodyOverride?: ExpectResponseBody; + } + ): BulkDeleteTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (forbidden) { + // override the expected result in each test case + cases = cases.map((x) => ({ ...x, failure: 403 })); + } + return cases.map((x) => ({ + title: getTestTitle(x, responseStatusCode), + responseStatusCode, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x, responseStatusCode), + })); + }; + + const makeBulkDeleteTest = + (describeFn: Mocha.SuiteFunction) => (description: string, definition: BulkDeleteTestSuite) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => + esArchiver.load( + 'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces' + ) + ); + after(() => + esArchiver.unload( + 'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces' + ) + ); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title} `, async () => { + const { type: testType, id: testId, force: testForce } = test.request; + const requestBody = [{ type: testType, id: testId }]; + const query = testForce && testForce === true ? '?force=true' : ''; + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_delete${query}`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeBulkDeleteTest(describe); + // @ts-ignore + addTests.only = makeBulkDeleteTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} 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 new file mode 100644 index 0000000000000..7a40ea564fb82 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_delete.ts @@ -0,0 +1,105 @@ +/* + * 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 { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + bulkDeleteTestSuiteFactory, + TEST_CASES as CASES, + BulkDeleteTestDefinition, +} from '../../common/suites/bulk_delete'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail400() }, + // try to delete this object again, this time using the `force` option + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, force: true }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail400(spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID), + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + // try to delete this object again, this time using the `force` option + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + force: true, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.ALIAS_DELETE_INCLUSIVE, force: true }, + { ...CASES.ALIAS_DELETE_EXCLUSIVE, force: true }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; // this behavior diverges from `delete`, which throws 404 + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { addTests, createTestDefinitions } = bulkDeleteTestSuiteFactory(es, esArchiver, supertest); + 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 }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { spaceId }), + }; + }; + + describe('_bulk_delete', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: BulkDeleteTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; + + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach((user) => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts index 4eb0b90480314..5cbb560427281 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts @@ -21,6 +21,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_get')); loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./bulk_resolve')); + loadTestFile(require.resolve('./bulk_delete')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./export')); 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 new file mode 100644 index 0000000000000..84f0c048f1a28 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_delete.ts @@ -0,0 +1,68 @@ +/* + * 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 { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { bulkDeleteTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_delete'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail400() }, + // // try to delete this object again, this time using the `force` option + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, force: true }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail400(spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID), + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + // try to delete this object again, this time using the `force` option + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + force: true, + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.ALIAS_DELETE_INCLUSIVE, force: true }, + { ...CASES.ALIAS_DELETE_EXCLUSIVE, force: true }, + { ...CASES.HIDDEN, ...fail400() }, + { ...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); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { spaceId }); + }; + + describe('_bulk_delete', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts index 1be7ed754a971..e17cdc43c16f9 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts @@ -15,6 +15,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./bulk_delete')); loadTestFile(require.resolve('./export')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get'));