From 3b6b7ad9b9553be3d039c71edcbdcb2e3d6423fd Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Thu, 11 May 2023 03:25:27 -0400 Subject: [PATCH] SavedObjectsRepository code cleanup (#157154) ## Summary Structural cleanup of the `SavedObjectsRepository` code, by extracting the actual implementation of each API to their individual file (as it was initiated for some by Joe a while ago, e.g `updateObjectsSpaces`) ### Why doing that, and why now? I remember discussing about this extraction with Joe for the first time like, what, almost 3 years ago? The 2.5k line SOR is a beast, and the only reason we did not refactor that yet is because of (the lack of) priorization of tech debt (and lack of courage, probably). So, why now? Well, with the changes we're planning to perform to the SOR code for serverless, I thought that doing a bit of cleanup beforehand was probably a wise thing. So I took this on-week time to tackle this (I know, so much for an on-week project, right?) ### API extraction All of these APIs in the SOR class now look like: ```ts /** * {@inheritDoc ISavedObjectsRepository.create} */ public async create( type: string, attributes: T, options: SavedObjectsCreateOptions = {} ): Promise> { return await performCreate( { type, attributes, options, }, this.apiExecutionContext ); } ``` This separation allows a better isolation, testability, readability and therefore maintainability overall. ### Structure ``` @kbn/core-saved-objects-api-server-internal - /src/lib - repository.ts - /apis - create.ts - delete.ts - .... - /helpers - /utils - /internals ``` There was a *massive* amount of helpers, utilities and such, both as internal functions on the SOR, and as external utilities. Some being stateless, some requiring access to parts of the SOR to perform calls... I introduced 3 concepts here, as you can see on the structure: #### utils Base utility functions, receiving (mostly) parameters from a given API call's option (e.g the type or id of a document, but not the type registry). #### helpers 'Stateful' helpers. These helpers were mostly here to receive the utility functions that were extracted from the SOR. They are instantiated with the SOR's context (e.g type registry, mappings and so on), to avoid the caller to such helpers to have to pass all the parameters again. #### internals I would call them 'utilities with business logic'. These are the 'big' chunks of logic called by the APIs. E.g `preflightCheckForCreate`, `internalBulkResolve` and so on. Note that given the legacy of the code, the frontier between those concept is quite thin sometimes, but I wanted to regroups things a bit, and also I aimed at increasing the developer experience by avoiding to call methods with 99 parameters (which is why the helpers were created). ### Tests Test coverage was not altered by this PR. The base repository tests (`packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts`) and the extension tests (`packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.{ext}_extension.test.ts`) were remain untouched. These tests are performing 'almost unmocked' tests, somewhat close to integration tests, so it would probably be worth keeping them. The new structure allow more low-level, unitary testing of the individual APIs. I did **NOT** add proper unit test coverage for the extracted APIs, as the amount of work it represent is way more significant than the refactor itself (and, once again, the existing coverage still applies / function here). The testing utilities and mocks were added in the PR though, and an example of what the per-API unit test could look like was also added (`packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/remove_references_to.test.ts`). Overall, I think it of course would be beneficial to add the missing unit test coverage, but given the amount of work it represent, and the fact that the code is already tested by the repository test and the (quite exhaustive) FTR test suites, I don't think it's worth the effort right now given our other priorities. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../README.md | 28 + .../priority_collection.test.ts.snap | 3 - .../src/lib/apis/bulk_create.ts | 313 ++ .../src/lib/apis/bulk_delete.ts | 325 ++ .../src/lib/apis/bulk_get.ts | 209 ++ .../src/lib/apis/bulk_resolve.ts | 68 + .../src/lib/apis/bulk_update.ts | 302 ++ .../src/lib/apis/check_conflicts.ts | 130 + .../collect_multinamespaces_references.ts | 41 + .../src/lib/apis/create.ts | 172 + .../src/lib/apis/delete.ts | 135 + .../src/lib/apis/delete_by_namespace.ts | 82 + .../src/lib/apis/find.ts | 268 ++ .../src/lib/apis/get.ts | 79 + .../src/lib/apis/helpers/common.ts | 120 + .../src/lib/apis/helpers/encryption.ts | 93 + .../src/lib/apis/helpers/index.ts | 31 + .../src/lib/apis/helpers/preflight_check.ts | 205 ++ .../src/lib/apis/helpers/serializer.ts | 51 + .../src/lib/apis/helpers/validation.ts | 124 + .../src/lib/apis/increment_counter.ts | 52 + .../src/lib/apis/index.ts | 27 + ...ct_multi_namespace_references.test.mock.ts | 14 +- ...collect_multi_namespace_references.test.ts | 6 +- .../collect_multi_namespace_references.ts | 22 +- .../internals/increment_counter_internal.ts | 171 + .../src/lib/apis/internals/index.ts | 15 + .../internal_bulk_resolve.test.mock.ts | 6 +- .../internals}/internal_bulk_resolve.test.ts | 6 +- .../internals}/internal_bulk_resolve.ts | 31 +- .../preflight_check_for_create.test.mock.ts | 10 +- .../preflight_check_for_create.test.ts | 2 +- .../internals}/preflight_check_for_create.ts | 26 +- .../update_objects_spaces.test.mock.ts | 10 +- .../internals}/update_objects_spaces.test.ts | 4 +- .../internals}/update_objects_spaces.ts | 54 +- .../src/lib/apis/open_point_in_time.ts | 95 + .../src/lib/apis/remove_references_to.test.ts | 76 + .../src/lib/apis/remove_references_to.ts | 90 + .../src/lib/apis/resolve.ts | 59 + .../src/lib/apis/types.ts | 29 + .../src/lib/apis/update.ts | 179 ++ .../src/lib/apis/update_objects_spaces.ts | 55 + .../src/lib/apis/utils/either.ts | 60 + .../src/lib/apis/utils/es_responses.ts | 21 + .../utils}/find_shared_origin_objects.test.ts | 4 +- .../utils}/find_shared_origin_objects.ts | 2 +- .../src/lib/apis/utils/index.ts | 25 + .../{ => apis/utils}/internal_utils.test.ts | 0 .../lib/{ => apis/utils}/internal_utils.ts | 66 +- .../src/lib/constants.ts | 11 + .../src/lib/priority_collection.test.ts | 59 - .../src/lib/priority_collection.ts | 37 - .../src/lib/repository.test.mock.ts | 24 +- .../src/lib/repository.ts | 2767 ++--------------- .../repository_bulk_delete_internal_types.ts | 2 +- .../src/mocks/api_context.mock.ts | 47 + .../src/mocks/api_helpers.mocks.ts | 110 + .../src/mocks/index.ts | 10 + .../src/mocks/migrator.mock.ts | 22 + .../test_helpers/repository.test.common.ts | 4 +- .../tsconfig.json | 1 + .../saved_objects_security_extension.ts | 2 +- 63 files changed, 4291 insertions(+), 2801 deletions(-) delete mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/__snapshots__/priority_collection.test.ts.snap create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_delete.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_get.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_resolve.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/check_conflicts.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/collect_multinamespaces_references.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/create.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/delete.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/delete_by_namespace.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/get.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/common.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/encryption.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/index.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/preflight_check.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/serializer.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/validation.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/increment_counter.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/index.ts rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/collect_multi_namespace_references.test.mock.ts (68%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/collect_multi_namespace_references.test.ts (99%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/collect_multi_namespace_references.ts (93%) create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/increment_counter_internal.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/index.ts rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/internal_bulk_resolve.test.mock.ts (87%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/internal_bulk_resolve.test.ts (99%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/internal_bulk_resolve.ts (96%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/preflight_check_for_create.test.mock.ts (72%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/preflight_check_for_create.test.ts (99%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/preflight_check_for_create.ts (93%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/update_objects_spaces.test.mock.ts (79%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/update_objects_spaces.test.ts (99%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/internals}/update_objects_spaces.ts (92%) create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/open_point_in_time.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/remove_references_to.test.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/remove_references_to.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/resolve.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/types.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update_objects_spaces.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/either.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/es_responses.ts rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/utils}/find_shared_origin_objects.test.ts (97%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/utils}/find_shared_origin_objects.ts (98%) create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/index.ts rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/utils}/internal_utils.test.ts (100%) rename packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/{ => apis/utils}/internal_utils.ts (87%) create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/constants.ts delete mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/priority_collection.test.ts delete mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/priority_collection.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/api_context.mock.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/api_helpers.mocks.ts create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/migrator.mock.ts diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/README.md b/packages/core/saved-objects/core-saved-objects-api-server-internal/README.md index 69dca03cec0515..b25fd29f5441c6 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/README.md +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/README.md @@ -1,3 +1,31 @@ # @kbn/core-saved-objects-api-server-internal This package contains the internal implementation of core's server-side savedObjects client and repository. + +## Structure of the package + +``` +@kbn/core-saved-objects-api-server-internal +- /src/lib + - repository.ts + - /apis + - create.ts + - delete.ts + - .... + - /helpers + - /utils + - /internals +``` + +### lib/apis/utils +Base utility functions, receiving (mostly) parameters from a given API call's option +(e.g the type or id of a document, but not the type registry). + +### lib/apis/helpers +'Stateful' helpers. These helpers were mostly here to receive the utility functions that were extracted from the SOR. +They are instantiated with the SOR's context (e.g type registry, mappings and so on), to avoid the caller to such +helpers to have to pass all the parameters again. + +### lib/apis/internals +I would call them 'utilities with business logic'. These are the 'big' chunks of logic called by the APIs. +E.g preflightCheckForCreate, internalBulkResolve and so on. \ No newline at end of file diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/__snapshots__/priority_collection.test.ts.snap b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/__snapshots__/priority_collection.test.ts.snap deleted file mode 100644 index fd96c54450cf7e..00000000000000 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/__snapshots__/priority_collection.test.ts.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`1, 1 throws Error 1`] = `"Already have entry with this priority"`; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.ts new file mode 100644 index 00000000000000..c7802397739c27 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.ts @@ -0,0 +1,313 @@ +/* + * 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 { + SavedObjectsErrorHelpers, + type SavedObject, + type SavedObjectSanitizedDoc, + DecoratedError, + AuthorizeCreateObject, + SavedObjectsRawDoc, +} from '@kbn/core-saved-objects-server'; +import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { + SavedObjectsCreateOptions, + SavedObjectsBulkCreateObject, + SavedObjectsBulkResponse, +} from '@kbn/core-saved-objects-api-server'; +import { DEFAULT_REFRESH_SETTING } from '../constants'; +import { + Either, + getBulkOperationError, + getCurrentTime, + getExpectedVersionProperties, + left, + right, + isLeft, + isRight, + normalizeNamespace, + setManaged, + errorContent, +} from './utils'; +import { getSavedObjectNamespaces } from './utils'; +import { PreflightCheckForCreateObject } from './internals/preflight_check_for_create'; +import { ApiExecutionContext } from './types'; + +export interface PerformBulkCreateParams { + objects: Array>; + options: SavedObjectsCreateOptions; +} + +type ExpectedResult = Either< + { type: string; id?: string; error: Payload }, + { + method: 'index' | 'create'; + object: SavedObjectsBulkCreateObject & { id: string }; + preflightCheckIndex?: number; + } +>; + +export const performBulkCreate = async ( + { objects, options }: PerformBulkCreateParams, + { + registry, + helpers, + allowedTypes, + client, + serializer, + migrator, + extensions = {}, + }: ApiExecutionContext +): Promise> => { + const { + common: commonHelper, + validation: validationHelper, + encryption: encryptionHelper, + preflight: preflightHelper, + serializer: serializerHelper, + } = helpers; + const { securityExtension } = extensions; + const namespace = commonHelper.getCurrentNamespace(options.namespace); + + const { + migrationVersionCompatibility, + overwrite = false, + refresh = DEFAULT_REFRESH_SETTING, + managed: optionsManaged, + } = options; + const time = getCurrentTime(); + + let preflightCheckIndexCounter = 0; + const expectedResults = objects.map((object) => { + const { type, id: requestId, initialNamespaces, version, managed } = object; + let error: DecoratedError | undefined; + let id: string = ''; // Assign to make TS happy, the ID will be validated (or randomly generated if needed) during getValidId below + const objectManaged = managed; + if (!allowedTypes.includes(type)) { + error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } else { + try { + id = commonHelper.getValidId(type, requestId, version, overwrite); + validationHelper.validateInitialNamespaces(type, initialNamespaces); + validationHelper.validateOriginId(type, object); + } catch (e) { + error = e; + } + } + + if (error) { + return left({ id: requestId, type, error: errorContent(error) }); + } + + const method = requestId && overwrite ? 'index' : 'create'; + const requiresNamespacesCheck = requestId && registry.isMultiNamespace(type); + + return right({ + method, + object: { + ...object, + id, + managed: setManaged({ optionsManaged, objectManaged }), + }, + ...(requiresNamespacesCheck && { preflightCheckIndex: preflightCheckIndexCounter++ }), + }) as ExpectedResult; + }); + + const validObjects = expectedResults.filter(isRight); + if (validObjects.length === 0) { + // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. + return { + // Technically the returned array should only contain SavedObject results, but for errors this is not true (we cast to 'unknown' below) + saved_objects: expectedResults.map>( + ({ value }) => value as unknown as SavedObject + ), + }; + } + + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + const preflightCheckObjects = validObjects + .filter(({ value }) => value.preflightCheckIndex !== undefined) + .map(({ value }) => { + const { type, id, initialNamespaces } = value.object; + const namespaces = initialNamespaces ?? [namespaceString]; + return { type, id, overwrite, namespaces }; + }); + const preflightCheckResponse = await preflightHelper.preflightCheckForCreate( + preflightCheckObjects + ); + + const authObjects: AuthorizeCreateObject[] = validObjects.map((element) => { + const { object, preflightCheckIndex: index } = element.value; + const preflightResult = index !== undefined ? preflightCheckResponse[index] : undefined; + return { + type: object.type, + id: object.id, + initialNamespaces: object.initialNamespaces, + existingNamespaces: preflightResult?.existingDocument?._source.namespaces ?? [], + }; + }); + + const authorizationResult = await securityExtension?.authorizeBulkCreate({ + namespace, + objects: authObjects, + }); + + let bulkRequestIndexCounter = 0; + const bulkCreateParams: object[] = []; + type ExpectedBulkResult = Either< + { type: string; id?: string; error: Payload }, + { esRequestIndex: number; requestedId: string; rawMigratedDoc: SavedObjectsRawDoc } + >; + const expectedBulkResults = await Promise.all( + expectedResults.map>(async (expectedBulkGetResult) => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } + + let savedObjectNamespace: string | undefined; + let savedObjectNamespaces: string[] | undefined; + let existingOriginId: string | undefined; + let versionProperties; + const { + preflightCheckIndex, + object: { initialNamespaces, version, ...object }, + method, + } = expectedBulkGetResult.value; + if (preflightCheckIndex !== undefined) { + const preflightResult = preflightCheckResponse[preflightCheckIndex]; + const { type, id, existingDocument, error } = preflightResult; + if (error) { + const { metadata } = error; + return left({ + id, + type, + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + ...(metadata && { metadata }), + }, + }); + } + savedObjectNamespaces = + initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); + versionProperties = getExpectedVersionProperties(version); + existingOriginId = existingDocument?._source?.originId; + } else { + if (registry.isSingleNamespace(object.type)) { + savedObjectNamespace = initialNamespaces + ? normalizeNamespace(initialNamespaces[0]) + : namespace; + } else if (registry.isMultiNamespace(object.type)) { + savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); + } + versionProperties = getExpectedVersionProperties(version); + } + + // 1. If the originId has been *explicitly set* in the options (defined or undefined), respect that. + // 2. Otherwise, preserve the originId of the existing object that is being overwritten, if any. + const originId = Object.keys(object).includes('originId') + ? object.originId + : existingOriginId; + const migrated = migrator.migrateDocument({ + id: object.id, + type: object.type, + attributes: await encryptionHelper.optionallyEncryptAttributes( + object.type, + object.id, + savedObjectNamespace, // only used for multi-namespace object types + object.attributes + ), + migrationVersion: object.migrationVersion, + coreMigrationVersion: object.coreMigrationVersion, + typeMigrationVersion: object.typeMigrationVersion, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + managed: setManaged({ optionsManaged, objectManaged: object.managed }), + updated_at: time, + created_at: time, + references: object.references || [], + originId, + }) as SavedObjectSanitizedDoc; + + /** + * If a validation has been registered for this type, we run it against the migrated attributes. + * This is an imperfect solution because malformed attributes could have already caused the + * migration to fail, but it's the best we can do without devising a way to run validations + * inside the migration algorithm itself. + */ + try { + validationHelper.validateObjectForCreate(object.type, migrated); + } catch (error) { + return left({ + id: object.id, + type: object.type, + error, + }); + } + + const expectedResult = { + esRequestIndex: bulkRequestIndexCounter++, + requestedId: object.id, + rawMigratedDoc: serializer.savedObjectToRaw(migrated), + }; + + bulkCreateParams.push( + { + [method]: { + _id: expectedResult.rawMigratedDoc._id, + _index: commonHelper.getIndexForType(object.type), + ...(overwrite && versionProperties), + }, + }, + expectedResult.rawMigratedDoc._source + ); + + return right(expectedResult); + }) + ); + + const bulkResponse = bulkCreateParams.length + ? await client.bulk({ + refresh, + require_alias: true, + body: bulkCreateParams, + }) + : undefined; + + const result = { + saved_objects: expectedBulkResults.map((expectedResult) => { + if (isLeft(expectedResult)) { + return expectedResult.value as any; + } + + const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value; + const rawResponse = Object.values(bulkResponse?.items[esRequestIndex] ?? {})[0] as any; + + const error = getBulkOperationError(rawMigratedDoc._source.type, requestedId, rawResponse); + if (error) { + return { type: rawMigratedDoc._source.type, id: requestedId, error }; + } + + // When method == 'index' the bulkResponse doesn't include the indexed + // _source so we return rawMigratedDoc but have to spread the latest + // _seq_no and _primary_term values from the rawResponse. + return serializerHelper.rawToSavedObject( + { + ...rawMigratedDoc, + ...{ _seq_no: rawResponse._seq_no, _primary_term: rawResponse._primary_term }, + }, + { migrationVersionCompatibility } + ); + }), + }; + return encryptionHelper.optionallyDecryptAndRedactBulkResult( + result, + authorizationResult?.typeMap, + objects + ); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_delete.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_delete.ts new file mode 100644 index 00000000000000..768dcf91b61c99 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_delete.ts @@ -0,0 +1,325 @@ +/* + * 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 pMap from 'p-map'; +import { + AuthorizeUpdateObject, + SavedObjectsErrorHelpers, + ISavedObjectTypeRegistry, + SavedObjectsRawDoc, +} from '@kbn/core-saved-objects-server'; +import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { + SavedObjectsBulkDeleteObject, + SavedObjectsBulkDeleteOptions, + SavedObjectsBulkDeleteResponse, +} from '@kbn/core-saved-objects-api-server'; +import { DEFAULT_REFRESH_SETTING, MAX_CONCURRENT_ALIAS_DELETIONS } from '../constants'; +import { + errorContent, + getBulkOperationError, + getExpectedVersionProperties, + isLeft, + isMgetDoc, + rawDocExistsInNamespace, + isRight, + left, + right, +} from './utils'; +import type { ApiExecutionContext } from './types'; +import { deleteLegacyUrlAliases } from '../legacy_url_aliases'; +import { + BulkDeleteExpectedBulkGetResult, + BulkDeleteItemErrorResult, + BulkDeleteParams, + ExpectedBulkDeleteMultiNamespaceDocsParams, + ExpectedBulkDeleteResult, + NewBulkItemResponse, + ObjectToDeleteAliasesFor, +} from '../repository_bulk_delete_internal_types'; + +export interface PerformBulkDeleteParams { + objects: SavedObjectsBulkDeleteObject[]; + options: SavedObjectsBulkDeleteOptions; +} + +export const performBulkDelete = async ( + { objects, options }: PerformBulkDeleteParams, + { + registry, + helpers, + allowedTypes, + client, + serializer, + extensions = {}, + logger, + mappings, + }: ApiExecutionContext +): Promise => { + const { common: commonHelper, preflight: preflightHelper } = helpers; + const { securityExtension } = extensions; + + const { refresh = DEFAULT_REFRESH_SETTING, force } = options; + const namespace = commonHelper.getCurrentNamespace(options.namespace); + + const expectedBulkGetResults = presortObjectsByNamespaceType(objects, allowedTypes, registry); + if (expectedBulkGetResults.length === 0) { + return { statuses: [] }; + } + + const multiNamespaceDocsResponse = await preflightHelper.preflightCheckForBulkDelete({ + expectedBulkGetResults, + namespace, + }); + + // First round of filtering (Left: object doesn't exist/doesn't exist in namespace, Right: good to proceed) + const expectedBulkDeleteMultiNamespaceDocsResults = + getExpectedBulkDeleteMultiNamespaceDocsResults( + { + expectedBulkGetResults, + multiNamespaceDocsResponse, + namespace, + force, + }, + registry + ); + + if (securityExtension) { + // Perform Auth Check (on both L/R, we'll deal with that later) + const authObjects: AuthorizeUpdateObject[] = expectedBulkDeleteMultiNamespaceDocsResults.map( + (element) => { + const index = (element.value as { esRequestIndex: number }).esRequestIndex; + const { type, id } = element.value; + const preflightResult = + index !== undefined ? multiNamespaceDocsResponse?.body.docs[index] : undefined; + + return { + type, + id, + // @ts-expect-error _source optional here + existingNamespaces: preflightResult?._source?.namespaces ?? [], + }; + } + ); + await securityExtension.authorizeBulkDelete({ namespace, objects: authObjects }); + } + + // Filter valid objects + const validObjects = expectedBulkDeleteMultiNamespaceDocsResults.filter(isRight); + if (validObjects.length === 0) { + // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. + const savedObjects = expectedBulkDeleteMultiNamespaceDocsResults + .filter(isLeft) + .map((expectedResult) => { + return { ...expectedResult.value, success: false }; + }); + return { statuses: [...savedObjects] }; + } + + // Create the bulkDeleteParams + const bulkDeleteParams: BulkDeleteParams[] = []; + validObjects.map((expectedResult) => { + bulkDeleteParams.push({ + delete: { + _id: serializer.generateRawId( + namespace, + expectedResult.value.type, + expectedResult.value.id + ), + _index: commonHelper.getIndexForType(expectedResult.value.type), + ...getExpectedVersionProperties(undefined), + }, + }); + }); + + const bulkDeleteResponse = bulkDeleteParams.length + ? await 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, + registry, + client, + getIndexForType: commonHelper.getIndexForType.bind(commonHelper), + type, + id, + namespaces, + deleteBehavior, + }).catch((err) => { + logger.error(`Unable to delete aliases when deleting an object: ${err.message}`); + }); + }; + await pMap(objectsToDeleteAliasesFor, mapper, { concurrency: MAX_CONCURRENT_ALIAS_DELETIONS }); + + return { statuses: [...savedObjects] }; +}; + +/** + * Performs initial checks on object type validity and flags multi-namespace objects for preflight checks by adding an `esRequestIndex` + * @returns array BulkDeleteExpectedBulkGetResult[] + */ +function presortObjectsByNamespaceType( + objects: SavedObjectsBulkDeleteObject[], + allowedTypes: string[], + registry: ISavedObjectTypeRegistry +) { + let bulkGetRequestIndexCounter = 0; + return objects.map((object) => { + const { type, id } = object; + if (!allowedTypes.includes(type)) { + return left({ + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), + }); + } + const requiresNamespacesCheck = registry.isMultiNamespace(type); + return right({ + type, + id, + ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + }); + }); +} + +/** + * @returns array of objects sorted by expected delete success or failure result + * @internal + */ +function getExpectedBulkDeleteMultiNamespaceDocsResults( + params: ExpectedBulkDeleteMultiNamespaceDocsParams, + registry: ISavedObjectTypeRegistry +): 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 left({ + 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 + if (!rawDocExistsInNamespace(registry, actualResult as SavedObjectsRawDoc, namespace)) { + return left({ + 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; + // 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 left({ + 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 right(expectedResult); + }); + return expectedBulkDeleteMultiNamespaceDocsResults; +} diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_get.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_get.ts new file mode 100644 index 00000000000000..19ac91083c8ce1 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_get.ts @@ -0,0 +1,209 @@ +/* + * 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 Boom, { Payload } from '@hapi/boom'; +import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; +import { + SavedObjectsErrorHelpers, + type SavedObject, + DecoratedError, + SavedObjectsRawDocSource, + AuthorizeBulkGetObject, +} from '@kbn/core-saved-objects-server'; +import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { + SavedObjectsBulkGetObject, + SavedObjectsBulkResponse, + SavedObjectsGetOptions, +} from '@kbn/core-saved-objects-api-server'; +import { + Either, + errorContent, + getSavedObjectFromSource, + isLeft, + isRight, + left, + right, + rawDocExistsInNamespaces, +} from './utils'; +import { ApiExecutionContext } from './types'; +import { includedFields } from '../included_fields'; + +export interface PerformBulkGetParams { + objects: SavedObjectsBulkGetObject[]; + options: SavedObjectsGetOptions; +} + +export const performBulkGet = async ( + { objects, options }: PerformBulkGetParams, + { helpers, allowedTypes, client, serializer, registry, extensions = {} }: ApiExecutionContext +): Promise> => { + const { + common: commonHelper, + validation: validationHelper, + encryption: encryptionHelper, + } = helpers; + const { securityExtension, spacesExtension } = extensions; + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + const { migrationVersionCompatibility } = options; + + if (objects.length === 0) { + return { saved_objects: [] }; + } + + let availableSpacesPromise: Promise | undefined; + const getAvailableSpaces = async () => { + if (!availableSpacesPromise) { + availableSpacesPromise = spacesExtension! + .getSearchableNamespaces([ALL_NAMESPACES_STRING]) + .catch((err) => { + if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { + // the user doesn't have access to any spaces; return the current space ID and allow the SOR authZ check to fail + return [SavedObjectsUtils.namespaceIdToString(namespace)]; + } else { + throw err; + } + }); + } + return availableSpacesPromise; + }; + + let bulkGetRequestIndexCounter = 0; + type ExpectedBulkGetResult = Either< + { type: string; id: string; error: Payload }, + { type: string; id: string; fields?: string[]; namespaces?: string[]; esRequestIndex: number } + >; + const expectedBulkGetResults = await Promise.all( + objects.map>(async (object) => { + const { type, id, fields } = object; + + let error: DecoratedError | undefined; + if (!allowedTypes.includes(type)) { + error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } else { + try { + validationHelper.validateObjectNamespaces(type, id, object.namespaces); + } catch (e) { + error = e; + } + } + + if (error) { + return left({ id, type, error: errorContent(error) }); + } + + let namespaces = object.namespaces; + if (spacesExtension && namespaces?.includes(ALL_NAMESPACES_STRING)) { + namespaces = await getAvailableSpaces(); + } + return right({ + type, + id, + fields, + namespaces, + esRequestIndex: bulkGetRequestIndexCounter++, + }); + }) + ); + + const validObjects = expectedBulkGetResults.filter(isRight); + if (validObjects.length === 0) { + // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. + return { + // Technically the returned array should only contain SavedObject results, but for errors this is not true (we cast to 'any' below) + saved_objects: expectedBulkGetResults.map>( + ({ value }) => value as unknown as SavedObject + ), + }; + } + + const getNamespaceId = (namespaces?: string[]) => + namespaces !== undefined ? SavedObjectsUtils.namespaceStringToId(namespaces[0]) : namespace; + const bulkGetDocs = validObjects.map(({ value: { type, id, fields, namespaces } }) => ({ + _id: serializer.generateRawId(getNamespaceId(namespaces), type, id), // the namespace prefix is only used for single-namespace object types + _index: commonHelper.getIndexForType(type), + _source: { includes: includedFields(type, fields) }, + })); + const bulkGetResponse = bulkGetDocs.length + ? await client.mget( + { + body: { + docs: bulkGetDocs, + }, + }, + { ignore: [404], meta: true } + ) + : undefined; + // fail fast if we can't verify a 404 is from Elasticsearch + if ( + bulkGetResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } + + const authObjects: AuthorizeBulkGetObject[] = []; + const result = { + saved_objects: expectedBulkGetResults.map((expectedResult) => { + if (isLeft(expectedResult)) { + const { type, id } = expectedResult.value; + authObjects.push({ type, id, existingNamespaces: [], error: true }); + return expectedResult.value as any; + } + + const { + type, + id, + // set to default namespaces value for `rawDocExistsInNamespaces` check below + namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)], + esRequestIndex, + } = expectedResult.value; + + const doc = bulkGetResponse?.body.docs[esRequestIndex]; + + // @ts-expect-error MultiGetHit._source is optional + const docNotFound = !doc?.found || !rawDocExistsInNamespaces(registry, doc, namespaces); + + authObjects.push({ + type, + id, + objectNamespaces: namespaces, + // @ts-expect-error MultiGetHit._source is optional + existingNamespaces: doc?._source?.namespaces ?? [], + error: docNotFound, + }); + + if (docNotFound) { + return { + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + } as any as SavedObject; + } + + // @ts-expect-error MultiGetHit._source is optional + return getSavedObjectFromSource(registry, type, id, doc, { + migrationVersionCompatibility, + }); + }), + }; + + const authorizationResult = await securityExtension?.authorizeBulkGet({ + namespace, + objects: authObjects, + }); + + return encryptionHelper.optionallyDecryptAndRedactBulkResult( + result, + authorizationResult?.typeMap + ); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_resolve.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_resolve.ts new file mode 100644 index 00000000000000..74ab7026eb177d --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_resolve.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { type SavedObject, BulkResolveError } from '@kbn/core-saved-objects-server'; +import { + SavedObjectsBulkResolveObject, + SavedObjectsBulkResolveResponse, + SavedObjectsResolveOptions, + SavedObjectsResolveResponse, +} from '@kbn/core-saved-objects-api-server'; +import { errorContent } from './utils'; +import { ApiExecutionContext } from './types'; +import { internalBulkResolve, isBulkResolveError } from './internals/internal_bulk_resolve'; +import { incrementCounterInternal } from './internals/increment_counter_internal'; + +export interface PerformCreateParams { + objects: SavedObjectsBulkResolveObject[]; + options: SavedObjectsResolveOptions; +} + +export const performBulkResolve = async ( + { objects, options }: PerformCreateParams, + apiExecutionContext: ApiExecutionContext +): Promise> => { + const { + registry, + helpers, + allowedTypes, + client, + serializer, + extensions = {}, + } = apiExecutionContext; + const { common: commonHelper } = helpers; + const { securityExtension, encryptionExtension } = extensions; + const namespace = commonHelper.getCurrentNamespace(options.namespace); + + const { resolved_objects: bulkResults } = await internalBulkResolve({ + registry, + allowedTypes, + client, + serializer, + getIndexForType: commonHelper.getIndexForType.bind(commonHelper), + incrementCounterInternal: (type, id, counterFields, opts = {}) => + incrementCounterInternal({ type, id, counterFields, options: opts }, apiExecutionContext), + encryptionExtension, + securityExtension, + objects, + options: { ...options, namespace }, + }); + const resolvedObjects = bulkResults.map>((result) => { + // extract payloads from saved object errors + if (isBulkResolveError(result)) { + const errorResult = result as BulkResolveError; + const { type, id, error } = errorResult; + return { + saved_object: { type, id, error: errorContent(error) } as unknown as SavedObject, + outcome: 'exactMatch', + }; + } + return result; + }); + return { resolved_objects: resolvedObjects }; +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts new file mode 100644 index 00000000000000..b9c0f10a9021ff --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -0,0 +1,302 @@ +/* + * 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 { Payload } from '@hapi/boom'; +import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; +import { + SavedObjectsErrorHelpers, + type SavedObject, + DecoratedError, + AuthorizeUpdateObject, + SavedObjectsRawDoc, +} from '@kbn/core-saved-objects-server'; +import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { encodeVersion } from '@kbn/core-saved-objects-base-server-internal'; +import { + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateOptions, + SavedObjectsBulkUpdateResponse, +} from '@kbn/core-saved-objects-api-server'; +import { DEFAULT_REFRESH_SETTING } from '../constants'; +import { + type Either, + errorContent, + getBulkOperationError, + getCurrentTime, + getExpectedVersionProperties, + isMgetDoc, + left, + right, + isLeft, + isRight, + rawDocExistsInNamespace, +} from './utils'; +import { ApiExecutionContext } from './types'; + +export interface PerformUpdateParams { + objects: Array>; + options: SavedObjectsBulkUpdateOptions; +} + +export const performBulkUpdate = async ( + { objects, options }: PerformUpdateParams, + { registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext +): Promise> => { + const { common: commonHelper, encryption: encryptionHelper } = helpers; + const { securityExtension } = extensions; + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + const time = getCurrentTime(); + + let bulkGetRequestIndexCounter = 0; + type DocumentToSave = Record; + type ExpectedBulkGetResult = Either< + { type: string; id: string; error: Payload }, + { + type: string; + id: string; + version?: string; + documentToSave: DocumentToSave; + objectNamespace?: string; + esRequestIndex?: number; + } + >; + const expectedBulkGetResults = objects.map((object) => { + const { type, id, attributes, references, version, namespace: objectNamespace } = object; + let error: DecoratedError | undefined; + if (!allowedTypes.includes(type)) { + error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } else { + try { + if (objectNamespace === ALL_NAMESPACES_STRING) { + error = SavedObjectsErrorHelpers.createBadRequestError('"namespace" cannot be "*"'); + } + } catch (e) { + error = e; + } + } + + if (error) { + return left({ id, type, error: errorContent(error) }); + } + + const documentToSave = { + [type]: attributes, + updated_at: time, + ...(Array.isArray(references) && { references }), + }; + + const requiresNamespacesCheck = registry.isMultiNamespace(object.type); + + return right({ + type, + id, + version, + documentToSave, + objectNamespace, + ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + }); + }); + + const validObjects = expectedBulkGetResults.filter(isRight); + if (validObjects.length === 0) { + // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. + return { + // Technically the returned array should only contain SavedObject results, but for errors this is not true (we cast to 'any' below) + saved_objects: expectedBulkGetResults.map>( + ({ value }) => value as unknown as SavedObject + ), + }; + } + + // `objectNamespace` is a namespace string, while `namespace` is a namespace ID. + // The object namespace string, if defined, will supersede the operation's namespace ID. + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + const getNamespaceId = (objectNamespace?: string) => + objectNamespace !== undefined + ? SavedObjectsUtils.namespaceStringToId(objectNamespace) + : namespace; + const getNamespaceString = (objectNamespace?: string) => objectNamespace ?? namespaceString; + const bulkGetDocs = validObjects + .filter(({ value }) => value.esRequestIndex !== undefined) + .map(({ value: { type, id, objectNamespace } }) => ({ + _id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id), + _index: commonHelper.getIndexForType(type), + _source: ['type', 'namespaces'], + })); + const bulkGetResponse = bulkGetDocs.length + ? await client.mget({ body: { docs: bulkGetDocs } }, { ignore: [404], meta: true }) + : undefined; + // fail fast if we can't verify a 404 response is from Elasticsearch + if ( + bulkGetResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } + + const authObjects: AuthorizeUpdateObject[] = validObjects.map((element) => { + const { type, id, objectNamespace, esRequestIndex: index } = element.value; + const preflightResult = index !== undefined ? bulkGetResponse?.body.docs[index] : undefined; + return { + type, + id, + objectNamespace, + // @ts-expect-error MultiGetHit._source is optional + existingNamespaces: preflightResult?._source?.namespaces ?? [], + }; + }); + + const authorizationResult = await securityExtension?.authorizeBulkUpdate({ + namespace, + objects: authObjects, + }); + + let bulkUpdateRequestIndexCounter = 0; + const bulkUpdateParams: object[] = []; + type ExpectedBulkUpdateResult = Either< + { type: string; id: string; error: Payload }, + { + type: string; + id: string; + namespaces: string[]; + documentToSave: DocumentToSave; + esRequestIndex: number; + } + >; + const expectedBulkUpdateResults = await Promise.all( + expectedBulkGetResults.map>(async (expectedBulkGetResult) => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } + + const { esRequestIndex, id, type, version, documentToSave, objectNamespace } = + expectedBulkGetResult.value; + + let namespaces; + let versionProperties; + if (esRequestIndex !== undefined) { + const indexFound = bulkGetResponse?.statusCode !== 404; + const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; + const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; + if ( + !docFound || + !rawDocExistsInNamespace( + registry, + actualResult as SavedObjectsRawDoc, + getNamespaceId(objectNamespace) + ) + ) { + return left({ + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + }); + } + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + namespaces = actualResult!._source.namespaces ?? [ + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), + ]; + versionProperties = getExpectedVersionProperties(version); + } else { + if (registry.isSingleNamespace(type)) { + // if `objectNamespace` is undefined, fall back to `options.namespace` + namespaces = [getNamespaceString(objectNamespace)]; + } + versionProperties = getExpectedVersionProperties(version); + } + + const expectedResult = { + type, + id, + namespaces, + esRequestIndex: bulkUpdateRequestIndexCounter++, + documentToSave: expectedBulkGetResult.value.documentToSave, + }; + + bulkUpdateParams.push( + { + update: { + _id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id), + _index: commonHelper.getIndexForType(type), + ...versionProperties, + }, + }, + { + doc: { + ...documentToSave, + [type]: await encryptionHelper.optionallyEncryptAttributes( + type, + id, + objectNamespace || namespace, + documentToSave[type] + ), + }, + } + ); + + return right(expectedResult); + }) + ); + + const { refresh = DEFAULT_REFRESH_SETTING } = options; + const bulkUpdateResponse = bulkUpdateParams.length + ? await client.bulk({ + refresh, + body: bulkUpdateParams, + _source_includes: ['originId'], + require_alias: true, + }) + : undefined; + + const result = { + saved_objects: expectedBulkUpdateResults.map((expectedResult) => { + if (isLeft(expectedResult)) { + return expectedResult.value as any; + } + + const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; + const response = bulkUpdateResponse?.items[esRequestIndex] ?? {}; + const rawResponse = Object.values(response)[0] as any; + + const error = getBulkOperationError(type, id, rawResponse); + if (error) { + return { type, id, error }; + } + + // When a bulk update operation is completed, any fields specified in `_sourceIncludes` will be found in the "get" value of the + // returned object. We need to retrieve the `originId` if it exists so we can return it to the consumer. + const { _seq_no: seqNo, _primary_term: primaryTerm, get } = rawResponse; + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { [type]: attributes, references, updated_at } = documentToSave; + + const { originId } = get._source; + return { + id, + type, + ...(namespaces && { namespaces }), + ...(originId && { originId }), + updated_at, + version: encodeVersion(seqNo, primaryTerm), + attributes, + references, + }; + }), + }; + + return encryptionHelper.optionallyDecryptAndRedactBulkResult( + result, + authorizationResult?.typeMap, + objects + ); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/check_conflicts.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/check_conflicts.ts new file mode 100644 index 00000000000000..6f110c3d6f908d --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/check_conflicts.ts @@ -0,0 +1,130 @@ +/* + * 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 { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; +import { + SavedObjectsErrorHelpers, + SavedObjectsRawDocSource, + SavedObjectsRawDoc, +} from '@kbn/core-saved-objects-server'; +import { + SavedObjectsCheckConflictsObject, + SavedObjectsBaseOptions, + SavedObjectsCheckConflictsResponse, +} from '@kbn/core-saved-objects-api-server'; +import { + Either, + errorContent, + left, + right, + isLeft, + isRight, + isMgetDoc, + rawDocExistsInNamespace, +} from './utils'; +import { ApiExecutionContext } from './types'; + +export interface PerformCheckConflictsParams { + objects: SavedObjectsCheckConflictsObject[]; + options: SavedObjectsBaseOptions; +} + +export const performCheckConflicts = async ( + { objects, options }: PerformCheckConflictsParams, + { registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext +): Promise => { + const { common: commonHelper } = helpers; + const { securityExtension } = extensions; + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + + if (objects.length === 0) { + return { errors: [] }; + } + + let bulkGetRequestIndexCounter = 0; + type ExpectedBulkGetResult = Either< + { type: string; id: string; error: Payload }, + { type: string; id: string; esRequestIndex: number } + >; + const expectedBulkGetResults = objects.map((object) => { + const { type, id } = object; + + if (!allowedTypes.includes(type)) { + return left({ + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), + }); + } + + return right({ + type, + id, + esRequestIndex: bulkGetRequestIndexCounter++, + }); + }); + + const validObjects = expectedBulkGetResults.filter(isRight); + await securityExtension?.authorizeCheckConflicts({ + namespace, + objects: validObjects.map((element) => ({ type: element.value.type, id: element.value.id })), + }); + + const bulkGetDocs = validObjects.map(({ value: { type, id } }) => ({ + _id: serializer.generateRawId(namespace, type, id), + _index: commonHelper.getIndexForType(type), + _source: { includes: ['type', 'namespaces'] }, + })); + const bulkGetResponse = bulkGetDocs.length + ? await client.mget( + { + body: { + docs: bulkGetDocs, + }, + }, + { ignore: [404], meta: true } + ) + : undefined; + // throw if we can't verify a 404 response is from Elasticsearch + if ( + bulkGetResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } + + const errors: SavedObjectsCheckConflictsResponse['errors'] = []; + expectedBulkGetResults.forEach((expectedResult) => { + if (isLeft(expectedResult)) { + errors.push(expectedResult.value as any); + return; + } + + const { type, id, esRequestIndex } = expectedResult.value; + const doc = bulkGetResponse?.body.docs[esRequestIndex]; + if (isMgetDoc(doc) && doc.found) { + errors.push({ + id, + type, + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + ...(!rawDocExistsInNamespace(registry, doc! as SavedObjectsRawDoc, namespace) && { + metadata: { isNotOverwritable: true }, + }), + }, + }); + } + }); + + return { errors }; +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/collect_multinamespaces_references.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/collect_multinamespaces_references.ts new file mode 100644 index 00000000000000..a107e74dcb5396 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/collect_multinamespaces_references.ts @@ -0,0 +1,41 @@ +/* + * 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 { + SavedObjectsCollectMultiNamespaceReferencesObject, + SavedObjectsCollectMultiNamespaceReferencesOptions, + SavedObjectsCollectMultiNamespaceReferencesResponse, +} from '@kbn/core-saved-objects-api-server'; +import { ApiExecutionContext } from './types'; +import { collectMultiNamespaceReferences } from './internals/collect_multi_namespace_references'; + +export interface PerformCreateParams { + objects: SavedObjectsCollectMultiNamespaceReferencesObject[]; + options: SavedObjectsCollectMultiNamespaceReferencesOptions; +} + +export const performCollectMultiNamespaceReferences = async ( + { objects, options }: PerformCreateParams, + { registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext +): Promise => { + const { common: commonHelper } = helpers; + const { securityExtension } = extensions; + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + return collectMultiNamespaceReferences({ + registry, + allowedTypes, + client, + serializer, + getIndexForType: commonHelper.getIndexForType.bind(commonHelper), + createPointInTimeFinder: commonHelper.createPointInTimeFinder.bind(commonHelper), + securityExtension, + objects, + options: { ...options, namespace }, + }); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/create.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/create.ts new file mode 100644 index 00000000000000..b8b9b8a0d2dbb7 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/create.ts @@ -0,0 +1,172 @@ +/* + * 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 { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; +import { + SavedObjectsErrorHelpers, + type SavedObject, + type SavedObjectSanitizedDoc, +} from '@kbn/core-saved-objects-server'; +import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { decodeRequestVersion } from '@kbn/core-saved-objects-base-server-internal'; +import { SavedObjectsCreateOptions } from '@kbn/core-saved-objects-api-server'; +import { DEFAULT_REFRESH_SETTING } from '../constants'; +import type { PreflightCheckForCreateResult } from './internals/preflight_check_for_create'; +import { getSavedObjectNamespaces, getCurrentTime, normalizeNamespace, setManaged } from './utils'; +import { ApiExecutionContext } from './types'; + +export interface PerformCreateParams { + type: string; + attributes: T; + options: SavedObjectsCreateOptions; +} + +export const performCreate = async ( + { type, attributes, options }: PerformCreateParams, + { + registry, + helpers, + allowedTypes, + client, + serializer, + migrator, + extensions = {}, + }: ApiExecutionContext +): Promise> => { + const { + common: commonHelper, + validation: validationHelper, + encryption: encryptionHelper, + preflight: preflightHelper, + serializer: serializerHelper, + } = helpers; + const { securityExtension } = extensions; + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + const { + migrationVersion, + coreMigrationVersion, + typeMigrationVersion, + managed, + overwrite = false, + references = [], + refresh = DEFAULT_REFRESH_SETTING, + initialNamespaces, + version, + } = options; + const { migrationVersionCompatibility } = options; + if (!allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } + const id = commonHelper.getValidId(type, options.id, options.version, options.overwrite); + validationHelper.validateInitialNamespaces(type, initialNamespaces); + validationHelper.validateOriginId(type, options); + + const time = getCurrentTime(); + let savedObjectNamespace: string | undefined; + let savedObjectNamespaces: string[] | undefined; + let existingOriginId: string | undefined; + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + + let preflightResult: PreflightCheckForCreateResult | undefined; + if (registry.isSingleNamespace(type)) { + savedObjectNamespace = initialNamespaces ? normalizeNamespace(initialNamespaces[0]) : namespace; + } else if (registry.isMultiNamespace(type)) { + if (options.id) { + // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces + // note: this check throws an error if the object is found but does not exist in this namespace + preflightResult = ( + await preflightHelper.preflightCheckForCreate([ + { + type, + id, + overwrite, + namespaces: initialNamespaces ?? [namespaceString], + }, + ]) + )[0]; + } + savedObjectNamespaces = + initialNamespaces || getSavedObjectNamespaces(namespace, preflightResult?.existingDocument); + existingOriginId = preflightResult?.existingDocument?._source?.originId; + } + + const authorizationResult = await securityExtension?.authorizeCreate({ + namespace, + object: { + type, + id, + initialNamespaces, + existingNamespaces: preflightResult?.existingDocument?._source?.namespaces ?? [], + }, + }); + + if (preflightResult?.error) { + // This intentionally occurs _after_ the authZ enforcement (which may throw a 403 error earlier) + throw SavedObjectsErrorHelpers.createConflictError(type, id); + } + + // 1. If the originId has been *explicitly set* in the options (defined or undefined), respect that. + // 2. Otherwise, preserve the originId of the existing object that is being overwritten, if any. + const originId = Object.keys(options).includes('originId') ? options.originId : existingOriginId; + const migrated = migrator.migrateDocument({ + id, + type, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + originId, + attributes: await encryptionHelper.optionallyEncryptAttributes( + type, + id, + savedObjectNamespace, // if single namespace type, this is the first in initialNamespaces. If multi-namespace type this is options.namespace/current namespace. + attributes + ), + migrationVersion, + coreMigrationVersion, + typeMigrationVersion, + managed: setManaged({ optionsManaged: managed }), + created_at: time, + updated_at: time, + ...(Array.isArray(references) && { references }), + }); + + /** + * If a validation has been registered for this type, we run it against the migrated attributes. + * This is an imperfect solution because malformed attributes could have already caused the + * migration to fail, but it's the best we can do without devising a way to run validations + * inside the migration algorithm itself. + */ + validationHelper.validateObjectForCreate(type, migrated as SavedObjectSanitizedDoc); + + const raw = serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); + + const requestParams = { + id: raw._id, + index: commonHelper.getIndexForType(type), + refresh, + body: raw._source, + ...(overwrite && version ? decodeRequestVersion(version) : {}), + require_alias: true, + }; + + const { body, statusCode, headers } = + id && overwrite + ? await client.index(requestParams, { meta: true }) + : await client.create(requestParams, { meta: true }); + + // throw if we can't verify a 404 response is from Elasticsearch + if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(id, type); + } + + return encryptionHelper.optionallyDecryptAndRedactSingleResult( + serializerHelper.rawToSavedObject({ ...raw, ...body }, { migrationVersionCompatibility }), + authorizationResult?.typeMap, + attributes + ); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/delete.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/delete.ts new file mode 100644 index 00000000000000..f687091ca8ab4c --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/delete.ts @@ -0,0 +1,135 @@ +/* + * 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 { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import { ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; +import { SavedObjectsDeleteOptions } from '@kbn/core-saved-objects-api-server'; +import { DEFAULT_REFRESH_SETTING } from '../constants'; +import { deleteLegacyUrlAliases } from '../legacy_url_aliases'; +import { getExpectedVersionProperties } from './utils'; +import { PreflightCheckNamespacesResult } from './helpers'; +import type { ApiExecutionContext } from './types'; + +export interface PerformDeleteParams { + type: string; + id: string; + options: SavedObjectsDeleteOptions; +} + +export const performDelete = async ( + { type, id, options }: PerformDeleteParams, + { + registry, + helpers, + allowedTypes, + client, + serializer, + extensions = {}, + logger, + mappings, + }: ApiExecutionContext +): Promise<{}> => { + const { common: commonHelper, preflight: preflightHelper } = helpers; + const { securityExtension } = extensions; + const namespace = commonHelper.getCurrentNamespace(options.namespace); + + if (!allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + const { refresh = DEFAULT_REFRESH_SETTING, force } = options; + + // we don't need to pass existing namespaces in because we're only concerned with authorizing + // the current space. This saves us from performing the preflight check if we're unauthorized + await securityExtension?.authorizeDelete({ + namespace, + object: { type, id }, + }); + + const rawId = serializer.generateRawId(namespace, type, id); + let preflightResult: PreflightCheckNamespacesResult | undefined; + + if (registry.isMultiNamespace(type)) { + // note: this check throws an error if the object is found but does not exist in this namespace + preflightResult = await preflightHelper.preflightCheckNamespaces({ + type, + id, + namespace, + }); + if ( + preflightResult.checkResult === 'found_outside_namespace' || + preflightResult.checkResult === 'not_found' + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + const existingNamespaces = preflightResult.savedObjectNamespaces ?? []; + if ( + !force && + (existingNamespaces.length > 1 || existingNamespaces.includes(ALL_NAMESPACES_STRING)) + ) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' + ); + } + } + + const { body, statusCode, headers } = await client.delete( + { + id: rawId, + index: commonHelper.getIndexForType(type), + ...getExpectedVersionProperties(undefined), + refresh, + }, + { ignore: [404], meta: true } + ); + + if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); + } + + const deleted = body.result === 'deleted'; + if (deleted) { + const namespaces = preflightResult?.savedObjectNamespaces; + if (namespaces) { + // This is a multi-namespace object type, and it might have legacy URL aliases that need to be deleted. + await deleteLegacyUrlAliases({ + mappings, + registry, + client, + getIndexForType: commonHelper.getIndexForType.bind(commonHelper), + type, + id, + ...(namespaces.includes(ALL_NAMESPACES_STRING) + ? { namespaces: [], deleteBehavior: 'exclusive' } // delete legacy URL aliases for this type/ID for all spaces + : { namespaces, deleteBehavior: 'inclusive' }), // delete legacy URL aliases for this type/ID for these specific spaces + }).catch((err) => { + // The object has already been deleted, but we caught an error when attempting to delete aliases. + // A consumer cannot attempt to delete the object again, so just log the error and swallow it. + logger.error(`Unable to delete aliases when deleting an object: ${err.message}`); + }); + } + return {}; + } + + const deleteDocNotFound = body.result === 'not_found'; + // @ts-expect-error @elastic/elasticsearch doesn't declare error on DeleteResponse + const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; + if (deleteDocNotFound || deleteIndexNotFound) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + throw new Error( + `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ + type, + id, + response: { body, statusCode }, + })}` + ); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/delete_by_namespace.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/delete_by_namespace.ts new file mode 100644 index 00000000000000..2848f8d6006585 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/delete_by_namespace.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as esKuery from '@kbn/es-query'; +import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import type { SavedObjectsDeleteByNamespaceOptions } from '@kbn/core-saved-objects-api-server'; +import { + getRootPropertiesObjects, + LEGACY_URL_ALIAS_TYPE, +} from '@kbn/core-saved-objects-base-server-internal'; +import type { ApiExecutionContext } from './types'; +import { getSearchDsl } from '../search_dsl'; + +export interface PerformDeleteByNamespaceParams { + namespace: string; + options: SavedObjectsDeleteByNamespaceOptions; +} + +export const performDeleteByNamespace = async ( + { namespace, options }: PerformDeleteByNamespaceParams, + { registry, helpers, client, mappings, extensions = {} }: ApiExecutionContext +): Promise => { + const { common: commonHelper } = helpers; + // This is not exposed on the SOC; authorization and audit logging is handled by the Spaces plugin + if (!namespace || typeof namespace !== 'string' || namespace === '*') { + throw new TypeError(`namespace is required, and must be a string that is not equal to '*'`); + } + + const allTypes = Object.keys(getRootPropertiesObjects(mappings)); + const typesToUpdate = [ + ...allTypes.filter((type) => !registry.isNamespaceAgnostic(type)), + LEGACY_URL_ALIAS_TYPE, + ]; + + // Construct kueryNode to filter legacy URL aliases (these space-agnostic objects do not use root-level "namespace/s" fields) + const { buildNode } = esKuery.nodeTypes.function; + const match1 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.targetNamespace`, namespace); + const match2 = buildNode('not', buildNode('is', 'type', LEGACY_URL_ALIAS_TYPE)); + const kueryNode = buildNode('or', [match1, match2]); + + const { body, statusCode, headers } = await client.updateByQuery( + { + index: commonHelper.getIndicesForTypes(typesToUpdate), + refresh: options.refresh, + body: { + script: { + source: ` + if (!ctx._source.containsKey('namespaces')) { + ctx.op = "delete"; + } else { + ctx._source['namespaces'].removeAll(Collections.singleton(params['namespace'])); + if (ctx._source['namespaces'].empty) { + ctx.op = "delete"; + } + } + `, + lang: 'painless', + params: { namespace }, + }, + conflicts: 'proceed', + ...getSearchDsl(mappings, registry, { + namespaces: [namespace], + type: typesToUpdate, + kueryNode, + }), + }, + }, + { ignore: [404], meta: true } + ); + // throw if we can't verify a 404 response is from Elasticsearch + if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } + + return body; +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.ts new file mode 100644 index 00000000000000..ec24818df90a08 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/find.ts @@ -0,0 +1,268 @@ +/* + * 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 Boom from '@hapi/boom'; +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { isSupportedEsServer } from '@kbn/core-elasticsearch-server-internal'; +import { + SavedObjectsErrorHelpers, + type SavedObjectsRawDoc, + CheckAuthorizationResult, + SavedObjectsRawDocSource, +} from '@kbn/core-saved-objects-server'; +import { + DEFAULT_NAMESPACE_STRING, + FIND_DEFAULT_PAGE, + FIND_DEFAULT_PER_PAGE, + SavedObjectsUtils, +} from '@kbn/core-saved-objects-utils-server'; +import { + SavedObjectsFindOptions, + SavedObjectsFindInternalOptions, + SavedObjectsFindResult, + SavedObjectsFindResponse, +} from '@kbn/core-saved-objects-api-server'; +import { ApiExecutionContext } from './types'; +import { validateConvertFilterToKueryNode } from '../filter_utils'; +import { validateAndConvertAggregations } from '../aggregations'; +import { includedFields } from '../included_fields'; +import { getSearchDsl } from '../search_dsl'; + +export interface PerformFindParams { + options: SavedObjectsFindOptions; + internalOptions: SavedObjectsFindInternalOptions; +} + +export const performFind = async ( + { options, internalOptions }: PerformFindParams, + { + registry, + helpers, + allowedTypes: rawAllowedTypes, + mappings, + client, + serializer, + migrator, + extensions = {}, + }: ApiExecutionContext +): Promise> => { + const { + common: commonHelper, + encryption: encryptionHelper, + serializer: serializerHelper, + } = helpers; + const { securityExtension, spacesExtension } = extensions; + let namespaces!: string[]; + const { disableExtensions } = internalOptions; + if (disableExtensions || !spacesExtension) { + namespaces = options.namespaces ?? [DEFAULT_NAMESPACE_STRING]; + // If the consumer specified `namespaces: []`, throw a Bad Request error + if (namespaces.length === 0) + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.namespaces cannot be an empty array' + ); + } + + const { + search, + defaultSearchOperator = 'OR', + searchFields, + rootSearchFields, + hasReference, + hasReferenceOperator, + hasNoReference, + hasNoReferenceOperator, + page = FIND_DEFAULT_PAGE, + perPage = FIND_DEFAULT_PER_PAGE, + pit, + searchAfter, + sortField, + sortOrder, + fields, + type, + filter, + preference, + aggs, + migrationVersionCompatibility, + } = options; + + if (!type) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.type must be a string or an array of strings' + ); + } else if (preference?.length && pit) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.preference must be excluded when options.pit is used' + ); + } + + const types = Array.isArray(type) ? type : [type]; + const allowedTypes = types.filter((t) => rawAllowedTypes.includes(t)); + if (allowedTypes.length === 0) { + return SavedObjectsUtils.createEmptyFindResponse(options); + } + + if (searchFields && !Array.isArray(searchFields)) { + throw SavedObjectsErrorHelpers.createBadRequestError('options.searchFields must be an array'); + } + + if (fields && !Array.isArray(fields)) { + throw SavedObjectsErrorHelpers.createBadRequestError('options.fields must be an array'); + } + + let kueryNode; + if (filter) { + try { + kueryNode = validateConvertFilterToKueryNode(allowedTypes, filter, mappings); + } catch (e) { + if (e.name === 'KQLSyntaxError') { + throw SavedObjectsErrorHelpers.createBadRequestError(`KQLSyntaxError: ${e.message}`); + } else { + throw e; + } + } + } + + let aggsObject; + if (aggs) { + try { + aggsObject = validateAndConvertAggregations(allowedTypes, aggs, mappings); + } catch (e) { + throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid aggregation: ${e.message}`); + } + } + + if (!disableExtensions && spacesExtension) { + try { + namespaces = await spacesExtension.getSearchableNamespaces(options.namespaces); + } catch (err) { + if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { + // The user is not authorized to access any space, return an empty response. + return SavedObjectsUtils.createEmptyFindResponse(options); + } + throw err; + } + if (namespaces.length === 0) { + // The user is authorized to access *at least one space*, but not any of the spaces they requested; return an empty response. + return SavedObjectsUtils.createEmptyFindResponse(options); + } + } + + // We have to first perform an initial authorization check so that we can construct the search DSL accordingly + const spacesToAuthorize = new Set(namespaces); + const typesToAuthorize = new Set(types); + let typeToNamespacesMap: Map | undefined; + let authorizationResult: CheckAuthorizationResult | undefined; + if (!disableExtensions && securityExtension) { + authorizationResult = await securityExtension.authorizeFind({ + namespaces: spacesToAuthorize, + types: typesToAuthorize, + }); + if (authorizationResult?.status === 'unauthorized') { + // If the user is unauthorized to find *anything* they requested, return an empty response + return SavedObjectsUtils.createEmptyFindResponse(options); + } + if (authorizationResult?.status === 'partially_authorized') { + typeToNamespacesMap = new Map(); + for (const [objType, entry] of authorizationResult.typeMap) { + if (!entry.find) continue; + // This ensures that the query DSL can filter only for object types that the user is authorized to access for a given space + const { authorizedSpaces, isGloballyAuthorized } = entry.find; + typeToNamespacesMap.set(objType, isGloballyAuthorized ? namespaces : authorizedSpaces); + } + } + } + + const esOptions = { + // If `pit` is provided, we drop the `index`, otherwise ES returns 400. + index: pit ? undefined : commonHelper.getIndicesForTypes(allowedTypes), + // If `searchAfter` is provided, we drop `from` as it will not be used for pagination. + from: searchAfter ? undefined : perPage * (page - 1), + _source: includedFields(allowedTypes, fields), + preference, + rest_total_hits_as_int: true, + size: perPage, + body: { + size: perPage, + seq_no_primary_term: true, + from: perPage * (page - 1), + _source: includedFields(allowedTypes, fields), + ...(aggsObject ? { aggs: aggsObject } : {}), + ...getSearchDsl(mappings, registry, { + search, + defaultSearchOperator, + searchFields, + pit, + rootSearchFields, + type: allowedTypes, + searchAfter, + sortField, + sortOrder, + namespaces, + typeToNamespacesMap, // If defined, this takes precedence over the `type` and `namespaces` fields + hasReference, + hasReferenceOperator, + hasNoReference, + hasNoReferenceOperator, + kueryNode, + }), + }, + }; + + const { body, statusCode, headers } = await client.search(esOptions, { + ignore: [404], + meta: true, + }); + if (statusCode === 404) { + if (!isSupportedEsServer(headers)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } + // 404 is only possible here if the index is missing, which + // we don't want to leak, see "404s from missing index" above + return SavedObjectsUtils.createEmptyFindResponse(options); + } + + const result = { + ...(body.aggregations ? { aggregations: body.aggregations as unknown as A } : {}), + page, + per_page: perPage, + total: body.hits.total, + saved_objects: body.hits.hits.map( + (hit: estypes.SearchHit): SavedObjectsFindResult => ({ + ...serializerHelper.rawToSavedObject(hit as SavedObjectsRawDoc, { + migrationVersionCompatibility, + }), + score: hit._score!, + sort: hit.sort, + }) + ), + pit_id: body.pit_id, + } as SavedObjectsFindResponse; + + if (disableExtensions) { + return result; + } + + // Now that we have a full set of results with all existing namespaces for each object, + // we need an updated authorization type map to pass on to the redact method + const redactTypeMap = await securityExtension?.getFindRedactTypeMap({ + previouslyCheckedNamespaces: spacesToAuthorize, + objects: result.saved_objects.map((obj) => { + return { + type: obj.type, + id: obj.id, + existingNamespaces: obj.namespaces ?? [], + }; + }), + }); + + return encryptionHelper.optionallyDecryptAndRedactBulkResult( + result, + redactTypeMap ?? authorizationResult?.typeMap // If the redact type map is valid, use that one; otherwise, fall back to the authorization check + ); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/get.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/get.ts new file mode 100644 index 00000000000000..d2fbe46fc6c85b --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/get.ts @@ -0,0 +1,79 @@ +/* + * 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 { isSupportedEsServer } from '@kbn/core-elasticsearch-server-internal'; +import { + SavedObjectsErrorHelpers, + type SavedObject, + SavedObjectsRawDocSource, +} from '@kbn/core-saved-objects-server'; +import { SavedObjectsGetOptions } from '@kbn/core-saved-objects-api-server'; +import { isFoundGetResponse, getSavedObjectFromSource, rawDocExistsInNamespace } from './utils'; +import { ApiExecutionContext } from './types'; + +export interface PerformGetParams { + type: string; + id: string; + options: SavedObjectsGetOptions; +} + +export const performGet = async ( + { type, id, options }: PerformGetParams, + { registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext +): Promise> => { + const { common: commonHelper, encryption: encryptionHelper } = helpers; + const { securityExtension } = extensions; + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + const { migrationVersionCompatibility } = options; + + if (!allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + const { body, statusCode, headers } = await client.get( + { + id: serializer.generateRawId(namespace, type, id), + index: commonHelper.getIndexForType(type), + }, + { ignore: [404], meta: true } + ); + const indexNotFound = statusCode === 404; + // check if we have the elasticsearch header when index is not found and, if we do, ensure it is from Elasticsearch + if (indexNotFound && !isSupportedEsServer(headers)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); + } + + const objectNotFound = + !isFoundGetResponse(body) || + indexNotFound || + !rawDocExistsInNamespace(registry, body, namespace); + + const authorizationResult = await securityExtension?.authorizeGet({ + namespace, + object: { + type, + id, + existingNamespaces: body?._source?.namespaces ?? [], + }, + objectNotFound, + }); + + if (objectNotFound) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + const result = getSavedObjectFromSource(registry, type, id, body, { + migrationVersionCompatibility, + }); + + return encryptionHelper.optionallyDecryptAndRedactSingleResult( + result, + authorizationResult?.typeMap + ); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/common.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/common.ts new file mode 100644 index 00000000000000..51b8723cd6d057 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/common.ts @@ -0,0 +1,120 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import type { + ISavedObjectTypeRegistry, + ISavedObjectsSpacesExtension, + ISavedObjectsEncryptionExtension, +} from '@kbn/core-saved-objects-server'; +import { getIndexForType } from '@kbn/core-saved-objects-base-server-internal'; +import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import { normalizeNamespace } from '../utils'; +import type { CreatePointInTimeFinderFn } from '../../point_in_time_finder'; + +export type ICommonHelper = PublicMethodsOf; + +export class CommonHelper { + private registry: ISavedObjectTypeRegistry; + private spaceExtension?: ISavedObjectsSpacesExtension; + private encryptionExtension?: ISavedObjectsEncryptionExtension; + private defaultIndex: string; + private kibanaVersion: string; + + public readonly createPointInTimeFinder: CreatePointInTimeFinderFn; + + constructor({ + registry, + createPointInTimeFinder, + spaceExtension, + encryptionExtension, + kibanaVersion, + defaultIndex, + }: { + registry: ISavedObjectTypeRegistry; + spaceExtension?: ISavedObjectsSpacesExtension; + encryptionExtension?: ISavedObjectsEncryptionExtension; + createPointInTimeFinder: CreatePointInTimeFinderFn; + defaultIndex: string; + kibanaVersion: string; + }) { + this.registry = registry; + this.spaceExtension = spaceExtension; + this.encryptionExtension = encryptionExtension; + this.kibanaVersion = kibanaVersion; + this.defaultIndex = defaultIndex; + this.createPointInTimeFinder = createPointInTimeFinder; + } + + /** + * Returns index specified by the given type or the default index + * + * @param type - the type + */ + public getIndexForType(type: string) { + return getIndexForType({ + type, + defaultIndex: this.defaultIndex, + typeRegistry: this.registry, + kibanaVersion: this.kibanaVersion, + }); + } + + /** + * Returns an array of indices as specified in `this._registry` for each of the + * given `types`. If any of the types don't have an associated index, the + * default index `this._index` will be included. + * + * @param types The types whose indices should be retrieved + */ + public getIndicesForTypes(types: string[]) { + return unique(types.map((t) => this.getIndexForType(t))); + } + + /** + * {@inheritDoc ISavedObjectsRepository.getCurrentNamespace} + */ + public getCurrentNamespace(namespace?: string) { + if (this.spaceExtension) { + return this.spaceExtension.getCurrentNamespace(namespace); + } + return normalizeNamespace(namespace); + } + + /** + * Saved objects with encrypted attributes should have IDs that are hard to guess, especially since IDs are part of the AAD used during + * encryption, that's why we control them within this function and don't allow consumers to specify their own IDs directly for encryptable + * types unless overwriting the original document. + */ + public getValidId( + type: string, + id: string | undefined, + version: string | undefined, + overwrite: boolean | undefined + ) { + if (!this.encryptionExtension?.isEncryptableType(type)) { + return id || SavedObjectsUtils.generateId(); + } + if (!id) { + return SavedObjectsUtils.generateId(); + } + // only allow a specified ID if we're overwriting an existing ESO with a Version + // this helps us ensure that the document really was previously created using ESO + // and not being used to get around the specified ID limitation + const canSpecifyID = (overwrite && version) || SavedObjectsUtils.isRandomId(id); + if (!canSpecifyID) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.' + ); + } + return id; + } +} + +const unique = (array: string[]) => [...new Set(array)]; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/encryption.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/encryption.ts new file mode 100644 index 00000000000000..e70f08b225c5ed --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/encryption.ts @@ -0,0 +1,93 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import { SavedObject } from '@kbn/core-saved-objects-common/src/server_types'; +import type { + AuthorizationTypeMap, + ISavedObjectsSecurityExtension, + ISavedObjectsEncryptionExtension, +} from '@kbn/core-saved-objects-server'; + +export type IEncryptionHelper = PublicMethodsOf; + +export class EncryptionHelper { + private securityExtension?: ISavedObjectsSecurityExtension; + private encryptionExtension?: ISavedObjectsEncryptionExtension; + + constructor({ + securityExtension, + encryptionExtension, + }: { + securityExtension?: ISavedObjectsSecurityExtension; + encryptionExtension?: ISavedObjectsEncryptionExtension; + }) { + this.securityExtension = securityExtension; + this.encryptionExtension = encryptionExtension; + } + + async optionallyEncryptAttributes( + type: string, + id: string, + namespaceOrNamespaces: string | string[] | undefined, + attributes: T + ): Promise { + if (!this.encryptionExtension?.isEncryptableType(type)) { + return attributes; + } + const namespace = Array.isArray(namespaceOrNamespaces) + ? namespaceOrNamespaces[0] + : namespaceOrNamespaces; + const descriptor = { type, id, namespace }; + return this.encryptionExtension.encryptAttributes( + descriptor, + attributes as Record + ) as unknown as T; + } + + async optionallyDecryptAndRedactSingleResult( + object: SavedObject, + typeMap: AuthorizationTypeMap | undefined, + originalAttributes?: T + ) { + if (this.encryptionExtension?.isEncryptableType(object.type)) { + object = await this.encryptionExtension.decryptOrStripResponseAttributes( + object, + originalAttributes + ); + } + if (typeMap) { + return this.securityExtension!.redactNamespaces({ typeMap, savedObject: object }); + } + return object; + } + + async optionallyDecryptAndRedactBulkResult< + T, + R extends { saved_objects: Array> }, + A extends string, + O extends Array<{ attributes: T }> + >(response: R, typeMap: AuthorizationTypeMap | undefined, originalObjects?: O) { + const modifiedObjects = await Promise.all( + response.saved_objects.map(async (object, index) => { + if (object.error) { + // If the bulk operation failed, the object will not have an attributes field at all, it will have an error field instead. + // In this case, don't attempt to decrypt, just return the object. + return object; + } + const originalAttributes = originalObjects?.[index].attributes; + return await this.optionallyDecryptAndRedactSingleResult( + object, + typeMap, + originalAttributes + ); + }) + ); + return { ...response, saved_objects: modifiedObjects }; + } +} diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/index.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/index.ts new file mode 100644 index 00000000000000..7c59a738ad3d73 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/index.ts @@ -0,0 +1,31 @@ +/* + * 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 { ICommonHelper } from './common'; +import type { IEncryptionHelper } from './encryption'; +import type { IValidationHelper } from './validation'; +import type { IPreflightCheckHelper } from './preflight_check'; +import type { ISerializerHelper } from './serializer'; + +export { CommonHelper } from './common'; +export { EncryptionHelper } from './encryption'; +export { ValidationHelper } from './validation'; +export { SerializerHelper } from './serializer'; +export { + PreflightCheckHelper, + type PreflightCheckNamespacesParams, + type PreflightCheckNamespacesResult, +} from './preflight_check'; + +export interface RepositoryHelpers { + common: ICommonHelper; + encryption: IEncryptionHelper; + validation: IValidationHelper; + preflight: IPreflightCheckHelper; + serializer: ISerializerHelper; +} diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/preflight_check.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/preflight_check.ts new file mode 100644 index 00000000000000..d51727ec3a8594 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/preflight_check.ts @@ -0,0 +1,205 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; +import type { + ISavedObjectTypeRegistry, + ISavedObjectsSerializer, +} from '@kbn/core-saved-objects-server'; +import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { SavedObjectsErrorHelpers, SavedObjectsRawDocSource } from '@kbn/core-saved-objects-server'; +import type { RepositoryEsClient } from '../../repository_es_client'; +import type { PreflightCheckForBulkDeleteParams } from '../../repository_bulk_delete_internal_types'; +import type { CreatePointInTimeFinderFn } from '../../point_in_time_finder'; +import { + getSavedObjectNamespaces, + isRight, + rawDocExistsInNamespaces, + isFoundGetResponse, + type GetResponseFound, +} from '../utils'; +import { + preflightCheckForCreate, + PreflightCheckForCreateObject, +} from '../internals/preflight_check_for_create'; + +export type IPreflightCheckHelper = PublicMethodsOf; + +export class PreflightCheckHelper { + private registry: ISavedObjectTypeRegistry; + private serializer: ISavedObjectsSerializer; + private client: RepositoryEsClient; + private getIndexForType: (type: string) => string; + private createPointInTimeFinder: CreatePointInTimeFinderFn; + + constructor({ + registry, + serializer, + client, + getIndexForType, + createPointInTimeFinder, + }: { + registry: ISavedObjectTypeRegistry; + serializer: ISavedObjectsSerializer; + client: RepositoryEsClient; + getIndexForType: (type: string) => string; + createPointInTimeFinder: CreatePointInTimeFinderFn; + }) { + this.registry = registry; + this.serializer = serializer; + this.client = client; + this.getIndexForType = getIndexForType; + this.createPointInTimeFinder = createPointInTimeFinder; + } + + public async preflightCheckForCreate(objects: PreflightCheckForCreateObject[]) { + return await preflightCheckForCreate({ + objects, + registry: this.registry, + client: this.client, + serializer: this.serializer, + getIndexForType: this.getIndexForType.bind(this), + createPointInTimeFinder: this.createPointInTimeFinder.bind(this), + }); + } + + /** + * 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 + */ + public 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; + } + + /** + * Pre-flight check to ensure that a multi-namespace object exists in the current namespace. + */ + public async preflightCheckNamespaces({ + type, + id, + namespace, + initialNamespaces, + }: PreflightCheckNamespacesParams): Promise { + if (!this.registry.isMultiNamespace(type)) { + throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`); + } + + const { body, statusCode, headers } = await this.client.get( + { + id: this.serializer.generateRawId(undefined, type, id), + index: this.getIndexForType(type), + }, + { + ignore: [404], + meta: true, + } + ); + + const namespaces = initialNamespaces ?? [SavedObjectsUtils.namespaceIdToString(namespace)]; + + const indexFound = statusCode !== 404; + if (indexFound && isFoundGetResponse(body)) { + if (!rawDocExistsInNamespaces(this.registry, body, namespaces)) { + return { checkResult: 'found_outside_namespace' }; + } + return { + checkResult: 'found_in_namespace', + savedObjectNamespaces: initialNamespaces ?? getSavedObjectNamespaces(namespace, body), + rawDocSource: body, + }; + } else if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { + // checking if the 404 is from Elasticsearch + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return { + checkResult: 'not_found', + savedObjectNamespaces: initialNamespaces ?? getSavedObjectNamespaces(namespace), + }; + } + + /** + * Pre-flight check to ensure that an upsert which would create a new object does not result in an alias conflict. + */ + public async preflightCheckForUpsertAliasConflict( + type: string, + id: string, + namespace: string | undefined + ) { + const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + const [{ error }] = await preflightCheckForCreate({ + registry: this.registry, + client: this.client, + serializer: this.serializer, + getIndexForType: this.getIndexForType.bind(this), + createPointInTimeFinder: this.createPointInTimeFinder.bind(this), + objects: [{ type, id, namespaces: [namespaceString] }], + }); + if (error?.type === 'aliasConflict') { + throw SavedObjectsErrorHelpers.createConflictError(type, id); + } + // any other error from this check does not matter + } +} + +/** + * @internal + */ +export interface PreflightCheckNamespacesParams { + /** The object type to fetch */ + type: string; + /** The object ID to fetch */ + id: string; + /** The current space */ + namespace: string | undefined; + /** Optional; for an object that is being created, this specifies the initial namespace(s) it will exist in (overriding the current space) */ + initialNamespaces?: string[]; +} + +/** + * @internal + */ +export interface PreflightCheckNamespacesResult { + /** If the object exists, and whether or not it exists in the current space */ + checkResult: 'not_found' | 'found_in_namespace' | 'found_outside_namespace'; + /** + * What namespace(s) the object should exist in, if it needs to be created; practically speaking, this will never be undefined if + * checkResult == not_found or checkResult == found_in_namespace + */ + savedObjectNamespaces?: string[]; + /** The source of the raw document, if the object already exists */ + rawDocSource?: GetResponseFound; +} diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/serializer.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/serializer.ts new file mode 100644 index 00000000000000..e5d609f590833f --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/serializer.ts @@ -0,0 +1,51 @@ +/* + * 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 { omit } from 'lodash'; +import type { PublicMethodsOf } from '@kbn/utility-types'; +import type { + ISavedObjectTypeRegistry, + ISavedObjectsSerializer, +} from '@kbn/core-saved-objects-server'; +import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { + SavedObject, + SavedObjectsRawDoc, + SavedObjectsRawDocParseOptions, +} from '@kbn/core-saved-objects-server'; + +export type ISerializerHelper = PublicMethodsOf; + +export class SerializerHelper { + private registry: ISavedObjectTypeRegistry; + private serializer: ISavedObjectsSerializer; + + constructor({ + registry, + serializer, + }: { + registry: ISavedObjectTypeRegistry; + serializer: ISavedObjectsSerializer; + }) { + this.registry = registry; + this.serializer = serializer; + } + + public rawToSavedObject( + raw: SavedObjectsRawDoc, + options?: SavedObjectsRawDocParseOptions + ): SavedObject { + const savedObject = this.serializer.rawToSavedObject(raw, options); + const { namespace, type } = savedObject; + if (this.registry.isSingleNamespace(type)) { + savedObject.namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)]; + } + + return omit(savedObject, ['namespace']) as SavedObject; + } +} diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/validation.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/validation.ts new file mode 100644 index 00000000000000..96224953ba459b --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/helpers/validation.ts @@ -0,0 +1,124 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import type { Logger } from '@kbn/logging'; +import type { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server'; +import { SavedObjectsTypeValidator } from '@kbn/core-saved-objects-base-server-internal'; +import { + SavedObjectsErrorHelpers, + type SavedObjectSanitizedDoc, +} from '@kbn/core-saved-objects-server'; +import { ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; + +export type IValidationHelper = PublicMethodsOf; + +export class ValidationHelper { + private registry: ISavedObjectTypeRegistry; + private logger: Logger; + private kibanaVersion: string; + private typeValidatorMap: Record = {}; + + constructor({ + registry, + logger, + kibanaVersion, + }: { + registry: ISavedObjectTypeRegistry; + logger: Logger; + kibanaVersion: string; + }) { + this.registry = registry; + this.logger = logger; + this.kibanaVersion = kibanaVersion; + } + + /** The `initialNamespaces` field (create, bulkCreate) is used to create an object in an initial set of spaces. */ + public validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) { + if (!initialNamespaces) { + return; + } + + if (this.registry.isNamespaceAgnostic(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" cannot be used on space-agnostic types' + ); + } else if (!initialNamespaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" must be a non-empty array of strings' + ); + } else if ( + !this.registry.isShareable(type) && + (initialNamespaces.length > 1 || initialNamespaces.includes(ALL_NAMESPACES_STRING)) + ) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ); + } + } + + /** The object-specific `namespaces` field (bulkGet) is used to check if an object exists in any of a given number of spaces. */ + public validateObjectNamespaces(type: string, id: string, namespaces: string[] | undefined) { + if (!namespaces) { + return; + } + + if (this.registry.isNamespaceAgnostic(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"namespaces" cannot be used on space-agnostic types' + ); + } else if (!namespaces.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } else if ( + !this.registry.isShareable(type) && + (namespaces.length > 1 || namespaces.includes(ALL_NAMESPACES_STRING)) + ) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"namespaces" can only specify a single space when used with space-isolated types' + ); + } + } + + /** Validate a migrated doc against the registered saved object type's schema. */ + public validateObjectForCreate(type: string, doc: SavedObjectSanitizedDoc) { + if (!this.registry.getType(type)) { + return; + } + const validator = this.getTypeValidator(type); + try { + validator.validate(doc, this.kibanaVersion); + } catch (error) { + throw SavedObjectsErrorHelpers.createBadRequestError(error.message); + } + } + + private getTypeValidator(type: string): SavedObjectsTypeValidator { + if (!this.typeValidatorMap[type]) { + const savedObjectType = this.registry.getType(type); + this.typeValidatorMap[type] = new SavedObjectsTypeValidator({ + logger: this.logger.get('type-validator'), + type, + validationMap: savedObjectType!.schemas ?? {}, + defaultVersion: this.kibanaVersion, + }); + } + return this.typeValidatorMap[type]!; + } + + /** This is used when objects are created. */ + public validateOriginId(type: string, objectOrOptions: { originId?: string }) { + if ( + Object.keys(objectOrOptions).includes('originId') && + !this.registry.isMultiNamespace(type) + ) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"originId" can only be set for multi-namespace object types' + ); + } + } +} diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/increment_counter.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/increment_counter.ts new file mode 100644 index 00000000000000..0a22b108d53b53 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/increment_counter.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { isObject } from 'lodash'; +import { SavedObjectsErrorHelpers, type SavedObject } from '@kbn/core-saved-objects-server'; +import { + SavedObjectsIncrementCounterField, + SavedObjectsIncrementCounterOptions, +} from '@kbn/core-saved-objects-api-server'; +import { ApiExecutionContext } from './types'; +import { incrementCounterInternal } from './internals'; + +export interface PerformIncrementCounterParams { + type: string; + id: string; + counterFields: Array; + options: SavedObjectsIncrementCounterOptions; +} + +export const performIncrementCounter = async ( + { type, id, counterFields, options }: PerformIncrementCounterParams, + apiExecutionContext: ApiExecutionContext +): Promise> => { + const { allowedTypes } = apiExecutionContext; + // This is not exposed on the SOC, there are no authorization or audit logging checks + if (typeof type !== 'string') { + throw new Error('"type" argument must be a string'); + } + + const isArrayOfCounterFields = + Array.isArray(counterFields) && + counterFields.every( + (field) => + typeof field === 'string' || (isObject(field) && typeof field.fieldName === 'string') + ); + + if (!isArrayOfCounterFields) { + throw new Error( + '"counterFields" argument must be of type Array' + ); + } + if (!allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } + + return incrementCounterInternal({ type, id, counterFields, options }, apiExecutionContext); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/index.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/index.ts new file mode 100644 index 00000000000000..3271f1ab25f211 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/index.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. + */ + +export type { ApiExecutionContext } from './types'; +export { performCreate } from './create'; +export { performBulkCreate } from './bulk_create'; +export { performDelete } from './delete'; +export { performCheckConflicts } from './check_conflicts'; +export { performBulkDelete } from './bulk_delete'; +export { performDeleteByNamespace } from './delete_by_namespace'; +export { performFind } from './find'; +export { performBulkGet } from './bulk_get'; +export { performGet } from './get'; +export { performUpdate } from './update'; +export { performBulkUpdate } from './bulk_update'; +export { performRemoveReferencesTo } from './remove_references_to'; +export { performOpenPointInTime } from './open_point_in_time'; +export { performIncrementCounter } from './increment_counter'; +export { performBulkResolve } from './bulk_resolve'; +export { performResolve } from './resolve'; +export { performUpdateObjectsSpaces } from './update_objects_spaces'; +export { performCollectMultiNamespaceReferences } from './collect_multinamespaces_references'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.test.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/collect_multi_namespace_references.test.mock.ts similarity index 68% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.test.mock.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/collect_multi_namespace_references.test.mock.ts index 5476f99c3b37dd..4debe9b5b3995d 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.test.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/collect_multi_namespace_references.test.mock.ts @@ -6,15 +6,15 @@ * Side Public License, v 1. */ -import type { findLegacyUrlAliases } from './legacy_url_aliases'; -import type { findSharedOriginObjects } from './find_shared_origin_objects'; -import type * as InternalUtils from './internal_utils'; +import type { findLegacyUrlAliases } from '../../legacy_url_aliases'; +import type { findSharedOriginObjects } from '../utils/find_shared_origin_objects'; +import type * as InternalUtils from '../utils/internal_utils'; export const mockFindLegacyUrlAliases = jest.fn() as jest.MockedFunction< typeof findLegacyUrlAliases >; -jest.mock('./legacy_url_aliases', () => { +jest.mock('../../legacy_url_aliases', () => { return { findLegacyUrlAliases: mockFindLegacyUrlAliases }; }); @@ -22,7 +22,7 @@ export const mockFindSharedOriginObjects = jest.fn() as jest.MockedFunction< typeof findSharedOriginObjects >; -jest.mock('./find_shared_origin_objects', () => { +jest.mock('../utils/find_shared_origin_objects', () => { return { findSharedOriginObjects: mockFindSharedOriginObjects }; }); @@ -30,8 +30,8 @@ export const mockRawDocExistsInNamespace = jest.fn() as jest.MockedFunction< typeof InternalUtils['rawDocExistsInNamespace'] >; -jest.mock('./internal_utils', () => { - const actual = jest.requireActual('./internal_utils'); +jest.mock('../utils/internal_utils', () => { + const actual = jest.requireActual('../utils/internal_utils'); return { ...actual, rawDocExistsInNamespace: mockRawDocExistsInNamespace, diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/collect_multi_namespace_references.test.ts similarity index 99% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.test.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/collect_multi_namespace_references.test.ts index b4e788dd2973aa..5450f0c739ce3c 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/collect_multi_namespace_references.test.ts @@ -24,13 +24,13 @@ import { type CollectMultiNamespaceReferencesParams, } from './collect_multi_namespace_references'; import { collectMultiNamespaceReferences } from './collect_multi_namespace_references'; -import type { CreatePointInTimeFinderFn } from './point_in_time_finder'; +import type { CreatePointInTimeFinderFn } from '../../point_in_time_finder'; import { enforceError, setupAuthorizeAndRedactMultiNamespaceReferenencesFailure, setupAuthorizeAndRedactMultiNamespaceReferenencesSuccess, -} from '../test_helpers/repository.test.common'; -import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock'; +} from '../../../test_helpers/repository.test.common'; +import { savedObjectsExtensionsMock } from '../../../mocks/saved_objects_extensions.mock'; import { type ISavedObjectsSecurityExtension, SavedObjectsErrorHelpers, diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/collect_multi_namespace_references.ts similarity index 93% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/collect_multi_namespace_references.ts index d8b25dc886f20c..260fa7c6adf16a 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/collect_multi_namespace_references.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/collect_multi_namespace_references.ts @@ -17,20 +17,20 @@ import { type ISavedObjectsSecurityExtension, type ISavedObjectTypeRegistry, type SavedObject, + type ISavedObjectsSerializer, SavedObjectsErrorHelpers, } from '@kbn/core-saved-objects-server'; import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { getObjectKey, parseObjectKey } from '@kbn/core-saved-objects-base-server-internal'; +import { findLegacyUrlAliases } from '../../legacy_url_aliases'; +import { getRootFields } from '../../included_fields'; +import type { CreatePointInTimeFinderFn } from '../../point_in_time_finder'; +import type { RepositoryEsClient } from '../../repository_es_client'; import { - type SavedObjectsSerializer, - getObjectKey, - parseObjectKey, -} from '@kbn/core-saved-objects-base-server-internal'; -import { findLegacyUrlAliases } from './legacy_url_aliases'; -import { getRootFields } from './included_fields'; -import { getSavedObjectFromSource, rawDocExistsInNamespace } from './internal_utils'; -import type { CreatePointInTimeFinderFn } from './point_in_time_finder'; -import type { RepositoryEsClient } from './repository_es_client'; -import { findSharedOriginObjects } from './find_shared_origin_objects'; + findSharedOriginObjects, + getSavedObjectFromSource, + rawDocExistsInNamespace, +} from '../utils'; /** * When we collect an object's outbound references, we will only go a maximum of this many levels deep before we throw an error. @@ -55,7 +55,7 @@ export interface CollectMultiNamespaceReferencesParams { registry: ISavedObjectTypeRegistry; allowedTypes: string[]; client: RepositoryEsClient; - serializer: SavedObjectsSerializer; + serializer: ISavedObjectsSerializer; getIndexForType: (type: string) => string; createPointInTimeFinder: CreatePointInTimeFinderFn; securityExtension: ISavedObjectsSecurityExtension | undefined; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/increment_counter_internal.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/increment_counter_internal.ts new file mode 100644 index 00000000000000..6ff63bc437189c --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/increment_counter_internal.ts @@ -0,0 +1,171 @@ +/* + * 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 { + SavedObjectsErrorHelpers, + type SavedObject, + type SavedObjectSanitizedDoc, + SavedObjectsRawDocSource, +} from '@kbn/core-saved-objects-server'; +import { encodeHitVersion } from '@kbn/core-saved-objects-base-server-internal'; +import { + SavedObjectsIncrementCounterOptions, + SavedObjectsIncrementCounterField, +} from '@kbn/core-saved-objects-api-server'; +import { DEFAULT_REFRESH_SETTING } from '../../constants'; +import { getCurrentTime, normalizeNamespace } from '../utils'; +import { ApiExecutionContext } from '../types'; + +export interface PerformIncrementCounterInternalParams { + type: string; + id: string; + counterFields: Array; + options: SavedObjectsIncrementCounterOptions; +} + +export const incrementCounterInternal = async ( + { type, id, counterFields, options }: PerformIncrementCounterInternalParams, + { registry, helpers, client, serializer, migrator }: ApiExecutionContext +): Promise> => { + const { common: commonHelper, preflight: preflightHelper } = helpers; + + const { + migrationVersion, + typeMigrationVersion, + refresh = DEFAULT_REFRESH_SETTING, + initialize = false, + upsertAttributes, + managed, + } = options; + + if (!id) { + throw SavedObjectsErrorHelpers.createBadRequestError('id cannot be empty'); // prevent potentially upserting a saved object with an empty ID + } + + const normalizedCounterFields = counterFields.map((counterField) => { + /** + * no counterField configs provided, instead a field name string was passed. + * ie `incrementCounter(so_type, id, ['my_field_name'])` + * Using the default of incrementing by 1 + */ + if (typeof counterField === 'string') { + return { + fieldName: counterField, + incrementBy: initialize ? 0 : 1, + }; + } + + const { incrementBy = 1, fieldName } = counterField; + + return { + fieldName, + incrementBy: initialize ? 0 : incrementBy, + }; + }); + const namespace = normalizeNamespace(options.namespace); + + const time = getCurrentTime(); + let savedObjectNamespace; + let savedObjectNamespaces: string[] | undefined; + + if (registry.isSingleNamespace(type) && namespace) { + savedObjectNamespace = namespace; + } else if (registry.isMultiNamespace(type)) { + // note: this check throws an error if the object is found but does not exist in this namespace + const preflightResult = await preflightHelper.preflightCheckNamespaces({ + type, + id, + namespace, + }); + if (preflightResult.checkResult === 'found_outside_namespace') { + throw SavedObjectsErrorHelpers.createConflictError(type, id); + } + + if (preflightResult.checkResult === 'not_found') { + // If an upsert would result in the creation of a new object, we need to check for alias conflicts too. + // This takes an extra round trip to Elasticsearch, but this won't happen often. + // TODO: improve performance by combining these into a single preflight check + await preflightHelper.preflightCheckForUpsertAliasConflict(type, id, namespace); + } + + savedObjectNamespaces = preflightResult.savedObjectNamespaces; + } + + // attributes: { [counterFieldName]: incrementBy }, + const migrated = migrator.migrateDocument({ + id, + type, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + attributes: { + ...(upsertAttributes ?? {}), + ...normalizedCounterFields.reduce((acc, counterField) => { + const { fieldName, incrementBy } = counterField; + acc[fieldName] = incrementBy; + return acc; + }, {} as Record), + }, + migrationVersion, + typeMigrationVersion, + managed, + updated_at: time, + }); + + const raw = serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); + + const body = await client.update({ + id: raw._id, + index: commonHelper.getIndexForType(type), + refresh, + require_alias: true, + _source: true, + body: { + script: { + source: ` + for (int i = 0; i < params.counterFieldNames.length; i++) { + def counterFieldName = params.counterFieldNames[i]; + def count = params.counts[i]; + + if (ctx._source[params.type][counterFieldName] == null) { + ctx._source[params.type][counterFieldName] = count; + } + else { + ctx._source[params.type][counterFieldName] += count; + } + } + ctx._source.updated_at = params.time; + `, + lang: 'painless', + params: { + counts: normalizedCounterFields.map( + (normalizedCounterField) => normalizedCounterField.incrementBy + ), + counterFieldNames: normalizedCounterFields.map( + (normalizedCounterField) => normalizedCounterField.fieldName + ), + time, + type, + }, + }, + upsert: raw._source, + }, + }); + + const { originId } = body.get?._source ?? {}; + return { + id, + type, + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + ...(originId && { originId }), + updated_at: time, + references: body.get?._source.references ?? [], + version: encodeHitVersion(body), + attributes: body.get?._source[type], + ...(managed && { managed }), + }; +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/index.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/index.ts new file mode 100644 index 00000000000000..a7e0b895bdc15e --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/index.ts @@ -0,0 +1,15 @@ +/* + * 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. + */ + +export { incrementCounterInternal } from './increment_counter_internal'; +export { + internalBulkResolve, + isBulkResolveError, + type InternalBulkResolveParams, + type InternalSavedObjectsBulkResolveResponse, +} from './internal_bulk_resolve'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.test.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve.test.mock.ts similarity index 87% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.test.mock.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve.test.mock.ts index 277d1ae4af34ac..51a88b5951288b 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.test.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve.test.mock.ts @@ -7,7 +7,7 @@ */ import type { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; -import type * as InternalUtils from './internal_utils'; +import type * as InternalUtils from '../utils/internal_utils'; export const mockGetSavedObjectFromSource = jest.fn() as jest.MockedFunction< typeof InternalUtils['getSavedObjectFromSource'] @@ -16,8 +16,8 @@ export const mockRawDocExistsInNamespace = jest.fn() as jest.MockedFunction< typeof InternalUtils['rawDocExistsInNamespace'] >; -jest.mock('./internal_utils', () => { - const actual = jest.requireActual('./internal_utils'); +jest.mock('../utils/internal_utils', () => { + const actual = jest.requireActual('../utils/internal_utils'); return { ...actual, getSavedObjectFromSource: mockGetSavedObjectFromSource, diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve.test.ts similarity index 99% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.test.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve.test.ts index 232c19fa7a8401..4b619764a042cf 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve.test.ts @@ -24,7 +24,7 @@ import { } from '@kbn/core-saved-objects-base-server-internal'; import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; import { internalBulkResolve, type InternalBulkResolveParams } from './internal_bulk_resolve'; -import { normalizeNamespace } from './internal_utils'; +import { normalizeNamespace } from '../utils'; import { type ISavedObjectsEncryptionExtension, type ISavedObjectsSecurityExtension, @@ -36,8 +36,8 @@ import { enforceError, setupAuthorizeAndRedactInternalBulkResolveFailure, setupAuthorizeAndRedactInternalBulkResolveSuccess, -} from '../test_helpers/repository.test.common'; -import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock'; +} from '../../../test_helpers/repository.test.common'; +import { savedObjectsExtensionsMock } from '../../../mocks/saved_objects_extensions.mock'; const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 }; const OBJ_TYPE = 'obj-type'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve.ts similarity index 96% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve.ts index 907474a008a3fb..6e143004dfb3a0 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve.ts @@ -23,12 +23,12 @@ import { type SavedObjectsRawDocSource, type SavedObject, type BulkResolveError, + type ISavedObjectsSerializer, SavedObjectsErrorHelpers, } from '@kbn/core-saved-objects-server'; import { LEGACY_URL_ALIAS_TYPE, type LegacyUrlAlias, - type SavedObjectsSerializer, } from '@kbn/core-saved-objects-base-server-internal'; import { CORE_USAGE_STATS_ID, @@ -45,8 +45,10 @@ import { type Right, isLeft, isRight, -} from './internal_utils'; -import type { RepositoryEsClient } from './repository_es_client'; + left, + right, +} from '../utils'; +import type { RepositoryEsClient } from '../../repository_es_client'; const MAX_CONCURRENT_RESOLVE = 10; @@ -59,7 +61,7 @@ export interface InternalBulkResolveParams { registry: ISavedObjectTypeRegistry; allowedTypes: string[]; client: RepositoryEsClient; - serializer: SavedObjectsSerializer; + serializer: ISavedObjectsSerializer; getIndexForType: (type: string) => string; incrementCounterInternal: ( type: string, @@ -271,26 +273,20 @@ function validateObjectTypes(objects: SavedObjectsBulkResolveObject[], allowedTy return objects.map>((object) => { const { type, id } = object; if (!allowedTypes.includes(type)) { - return { - tag: 'Left', - value: { - type, - id, - error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type), - }, - }; + return left({ + type, + id, + error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type), + }); } - return { - tag: 'Right', - value: object, - }; + return right(object); }); } async function fetchAndUpdateAliases( validObjects: Array>, client: RepositoryEsClient, - serializer: SavedObjectsSerializer, + serializer: ISavedObjectsSerializer, getIndexForType: (type: string) => string, namespace: string | undefined ) { @@ -342,6 +338,7 @@ async function fetchAndUpdateAliases( return item.update?.get; }); } + class ResolveCounter { private record = new Map(); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.test.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/preflight_check_for_create.test.mock.ts similarity index 72% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.test.mock.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/preflight_check_for_create.test.mock.ts index fe8076b51e5dd4..e48f4ed1f69070 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.test.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/preflight_check_for_create.test.mock.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import type { findLegacyUrlAliases } from './legacy_url_aliases'; -import type * as InternalUtils from './internal_utils'; +import type { findLegacyUrlAliases } from '../../legacy_url_aliases'; +import type * as InternalUtils from '../utils/internal_utils'; export const mockFindLegacyUrlAliases = jest.fn() as jest.MockedFunction< typeof findLegacyUrlAliases >; -jest.mock('./legacy_url_aliases', () => { +jest.mock('../../legacy_url_aliases', () => { return { findLegacyUrlAliases: mockFindLegacyUrlAliases }; }); @@ -21,8 +21,8 @@ export const mockRawDocExistsInNamespaces = jest.fn() as jest.MockedFunction< typeof InternalUtils['rawDocExistsInNamespaces'] >; -jest.mock('./internal_utils', () => { - const actual = jest.requireActual('./internal_utils'); +jest.mock('../utils/internal_utils', () => { + const actual = jest.requireActual('../utils/internal_utils'); return { ...actual, rawDocExistsInNamespaces: mockRawDocExistsInNamespaces, diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/preflight_check_for_create.test.ts similarity index 99% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.test.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/preflight_check_for_create.test.ts index f2901e4b531878..c07134259d4b86 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/preflight_check_for_create.test.ts @@ -20,7 +20,7 @@ import { LEGACY_URL_ALIAS_TYPE, } from '@kbn/core-saved-objects-base-server-internal'; import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks'; -import type { CreatePointInTimeFinderFn } from './point_in_time_finder'; +import type { CreatePointInTimeFinderFn } from '../../point_in_time_finder'; import { ALIAS_SEARCH_PER_PAGE, type PreflightCheckForCreateObject, diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/preflight_check_for_create.ts similarity index 93% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/preflight_check_for_create.ts index ae09b0e1e42280..aec1ac9991d4cd 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/preflight_check_for_create.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/preflight_check_for_create.ts @@ -10,6 +10,7 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; import { type ISavedObjectTypeRegistry, + type ISavedObjectsSerializer, type SavedObjectsRawDoc, type SavedObjectsRawDocSource, SavedObjectsErrorHelpers, @@ -19,13 +20,11 @@ import { LEGACY_URL_ALIAS_TYPE, getObjectKey, type LegacyUrlAlias, - type SavedObjectsSerializer, } from '@kbn/core-saved-objects-base-server-internal'; -import { findLegacyUrlAliases } from './legacy_url_aliases'; -import { type Either, rawDocExistsInNamespaces } from './internal_utils'; -import { isLeft, isRight } from './internal_utils'; -import type { CreatePointInTimeFinderFn } from './point_in_time_finder'; -import type { RepositoryEsClient } from './repository_es_client'; +import { findLegacyUrlAliases } from '../../legacy_url_aliases'; +import type { CreatePointInTimeFinderFn } from '../../point_in_time_finder'; +import type { RepositoryEsClient } from '../../repository_es_client'; +import { left, right, isLeft, isRight, rawDocExistsInNamespaces, type Either } from '../utils'; /** * If the object will be created in this many spaces (or "*" all current and future spaces), we use find to fetch all aliases. @@ -56,7 +55,7 @@ export interface PreflightCheckForCreateObject { export interface PreflightCheckForCreateParams { registry: ISavedObjectTypeRegistry; client: RepositoryEsClient; - serializer: SavedObjectsSerializer; + serializer: ISavedObjectsSerializer; getIndexForType: (type: string) => string; createPointInTimeFinder: CreatePointInTimeFinderFn; objects: PreflightCheckForCreateObject[]; @@ -201,9 +200,10 @@ async function optionallyFindAliases( const objectsToGetOrObjectsToFind = objects.map>((object) => { const { type, id, namespaces, overwrite = false } = object; const spaces = new Set(namespaces); - const tag = - spaces.size > FIND_ALIASES_THRESHOLD || spaces.has(ALL_NAMESPACES_STRING) ? 'Right' : 'Left'; - return { tag, value: { type, id, overwrite, spaces } }; + const value = { type, id, overwrite, spaces }; + return spaces.size > FIND_ALIASES_THRESHOLD || spaces.has(ALL_NAMESPACES_STRING) + ? right(value) + : left(value); }); const objectsToFind = objectsToGetOrObjectsToFind @@ -236,13 +236,13 @@ async function optionallyFindAliases( } if (spacesWithConflictingAliases.length) { // we found one or more conflicting aliases, this is an error result - return { tag: 'Left', value: { ...either.value, spacesWithConflictingAliases } }; + return left({ ...either.value, spacesWithConflictingAliases }); } } // we checked for aliases but did not detect any conflicts; make sure we don't check for aliases again during mget checkAliases = false; } - return { tag: 'Right', value: { ...either.value, checkAliases } }; + return right({ ...either.value, checkAliases }); }); return result; @@ -250,7 +250,7 @@ async function optionallyFindAliases( async function bulkGetObjectsAndAliases( client: RepositoryEsClient, - serializer: SavedObjectsSerializer, + serializer: ISavedObjectsSerializer, getIndexForType: (type: string) => string, objectsAndAliasesToBulkGet: Array ) { diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.test.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/update_objects_spaces.test.mock.ts similarity index 79% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.test.mock.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/update_objects_spaces.test.mock.ts index 043975d5bb52b1..20fa8daaac167d 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.test.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/update_objects_spaces.test.mock.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import type * as InternalUtils from './internal_utils'; -import type { deleteLegacyUrlAliases } from './legacy_url_aliases'; +import type * as InternalUtils from '../utils/internal_utils'; +import type { deleteLegacyUrlAliases } from '../../legacy_url_aliases'; export const mockGetBulkOperationError = jest.fn() as jest.MockedFunction< typeof InternalUtils['getBulkOperationError'] @@ -19,8 +19,8 @@ export const mockRawDocExistsInNamespace = jest.fn() as jest.MockedFunction< typeof InternalUtils['rawDocExistsInNamespace'] >; -jest.mock('./internal_utils', () => { - const actual = jest.requireActual('./internal_utils'); +jest.mock('../utils/internal_utils', () => { + const actual = jest.requireActual('../utils/internal_utils'); return { ...actual, getBulkOperationError: mockGetBulkOperationError, @@ -32,6 +32,6 @@ jest.mock('./internal_utils', () => { export const mockDeleteLegacyUrlAliases = jest.fn() as jest.MockedFunction< typeof deleteLegacyUrlAliases >; -jest.mock('./legacy_url_aliases', () => ({ +jest.mock('../../legacy_url_aliases', () => ({ deleteLegacyUrlAliases: mockDeleteLegacyUrlAliases, })); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/update_objects_spaces.test.ts similarity index 99% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.test.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/update_objects_spaces.test.ts index 7432b7ae3e6aed..f96c3dfddc8d0a 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/update_objects_spaces.test.ts @@ -32,8 +32,8 @@ import { setupRedactPassthrough, authMap, setupAuthorizeFunc, -} from '../test_helpers/repository.test.common'; -import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock'; +} from '../../../test_helpers/repository.test.common'; +import { savedObjectsExtensionsMock } from '../../../mocks/saved_objects_extensions.mock'; type SetupParams = Partial< Pick diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/update_objects_spaces.ts similarity index 92% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/update_objects_spaces.ts index dd6bdc5c3e17d2..62b006cc0d9acb 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/update_objects_spaces.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/internals/update_objects_spaces.ts @@ -23,14 +23,12 @@ import type { AuthorizeObjectWithExistingSpaces, ISavedObjectsSecurityExtension, ISavedObjectTypeRegistry, + ISavedObjectsSerializer, SavedObjectsRawDocSource, } from '@kbn/core-saved-objects-server'; import { ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; import { SavedObjectsErrorHelpers, type DecoratedError } from '@kbn/core-saved-objects-server'; -import type { - IndexMapping, - SavedObjectsSerializer, -} from '@kbn/core-saved-objects-base-server-internal'; +import type { IndexMapping } from '@kbn/core-saved-objects-base-server-internal'; import { getBulkOperationError, getExpectedVersionProperties, @@ -38,11 +36,15 @@ import { type Either, isLeft, isRight, -} from './internal_utils'; -import { DEFAULT_REFRESH_SETTING } from './repository'; -import type { RepositoryEsClient } from './repository_es_client'; -import type { DeleteLegacyUrlAliasesParams } from './legacy_url_aliases'; -import { deleteLegacyUrlAliases } from './legacy_url_aliases'; + left, + right, +} from '../utils'; +import { DEFAULT_REFRESH_SETTING } from '../../constants'; +import type { RepositoryEsClient } from '../../repository_es_client'; +import { + deleteLegacyUrlAliases, + type DeleteLegacyUrlAliasesParams, +} from '../../legacy_url_aliases'; /** * Parameters for the updateObjectsSpaces function. @@ -54,7 +56,7 @@ export interface UpdateObjectsSpacesParams { registry: ISavedObjectTypeRegistry; allowedTypes: string[]; client: RepositoryEsClient; - serializer: SavedObjectsSerializer; + serializer: ISavedObjectsSerializer; logger: Logger; getIndexForType: (type: string) => string; securityExtension: ISavedObjectsSecurityExtension | undefined; @@ -117,10 +119,7 @@ export async function updateObjectsSpaces({ if (!allowedTypes.includes(type)) { const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); - return { - tag: 'Left', - value: { id, type, spaces: [], error }, - }; + return left({ id, type, spaces: [], error }); } if (!registry.isShareable(type)) { const error = errorContent( @@ -128,21 +127,15 @@ export async function updateObjectsSpaces({ `${type} doesn't support multiple namespaces` ) ); - return { - tag: 'Left', - value: { id, type, spaces: [], error }, - }; + return left({ id, type, spaces: [], error }); } - return { - tag: 'Right', - value: { - type, - id, - version, - esRequestIndex: bulkGetRequestIndexCounter++, - }, - }; + return right({ + type, + id, + version, + esRequestIndex: bulkGetRequestIndexCounter++, + }); }); const validObjects = expectedBulkGetResults.filter(isRight); @@ -217,10 +210,7 @@ export async function updateObjectsSpaces({ !rawDocExistsInNamespace(registry, doc, namespace) ) { const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); - return { - tag: 'Left', - value: { id, type, spaces: [], error }, - }; + return left({ id, type, spaces: [], error }); } const currentSpaces = doc._source?.namespaces ?? []; @@ -265,7 +255,7 @@ export async function updateObjectsSpaces({ } } - return { tag: 'Right', value: expectedResult }; + return right(expectedResult); }); const { refresh = DEFAULT_REFRESH_SETTING } = options; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/open_point_in_time.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/open_point_in_time.ts new file mode 100644 index 00000000000000..e5cf78c4185d83 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/open_point_in_time.ts @@ -0,0 +1,95 @@ +/* + * 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 Boom from '@hapi/boom'; +import { isSupportedEsServer } from '@kbn/core-elasticsearch-server-internal'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; +import { + SavedObjectsOpenPointInTimeOptions, + SavedObjectsFindInternalOptions, + SavedObjectsOpenPointInTimeResponse, +} from '@kbn/core-saved-objects-api-server'; +import { ApiExecutionContext } from './types'; + +export interface PerforOpenPointInTimeParams { + type: string | string[]; + options: SavedObjectsOpenPointInTimeOptions; + internalOptions: SavedObjectsFindInternalOptions; +} + +export const performOpenPointInTime = async ( + { type, options, internalOptions }: PerforOpenPointInTimeParams, + { helpers, allowedTypes: rawAllowedTypes, client, extensions = {} }: ApiExecutionContext +): Promise => { + const { common: commonHelper } = helpers; + const { securityExtension, spacesExtension } = extensions; + const { disableExtensions } = internalOptions; + let namespaces!: string[]; + if (disableExtensions || !spacesExtension) { + namespaces = options.namespaces ?? [DEFAULT_NAMESPACE_STRING]; + // If the consumer specified `namespaces: []`, throw a Bad Request error + if (namespaces.length === 0) + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.namespaces cannot be an empty array' + ); + } + + const { keepAlive = '5m', preference } = options; + const types = Array.isArray(type) ? type : [type]; + const allowedTypes = types.filter((t) => rawAllowedTypes.includes(t)); + if (allowedTypes.length === 0) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + } + + if (!disableExtensions && spacesExtension) { + try { + namespaces = await spacesExtension.getSearchableNamespaces(options.namespaces); + } catch (err) { + if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { + // The user is not authorized to access any space, throw a bad request error. + throw SavedObjectsErrorHelpers.createBadRequestError(); + } + throw err; + } + if (namespaces.length === 0) { + // The user is authorized to access *at least one space*, but not any of the spaces they requested; throw a bad request error. + throw SavedObjectsErrorHelpers.createBadRequestError(); + } + } + + if (!disableExtensions && securityExtension) { + await securityExtension.authorizeOpenPointInTime({ + namespaces: new Set(namespaces), + types: new Set(types), + }); + } + + const esOptions = { + index: commonHelper.getIndicesForTypes(allowedTypes), + keep_alive: keepAlive, + ...(preference ? { preference } : {}), + }; + + const { body, statusCode, headers } = await client.openPointInTime(esOptions, { + ignore: [404], + meta: true, + }); + + if (statusCode === 404) { + if (!isSupportedEsServer(headers)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } else { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + } + } + + return { + id: body.id, + }; +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/remove_references_to.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/remove_references_to.test.ts new file mode 100644 index 00000000000000..78c3e8d1faf925 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/remove_references_to.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { apiContextMock, ApiExecutionContextMock } from '../../mocks'; +import { createType } from '../../test_helpers/repository.test.common'; +import { performRemoveReferencesTo } from './remove_references_to'; + +const fooType = createType('foo', {}); +const barType = createType('bar', {}); + +describe('performRemoveReferencesTo', () => { + const namespace = 'some_ns'; + const indices = ['.kib_1', '.kib_2']; + let apiExecutionContext: ApiExecutionContextMock; + + beforeEach(() => { + apiExecutionContext = apiContextMock.create(); + apiExecutionContext.registry.registerType(fooType); + apiExecutionContext.registry.registerType(barType); + + apiExecutionContext.helpers.common.getCurrentNamespace.mockImplementation( + (space) => space ?? 'default' + ); + apiExecutionContext.helpers.common.getIndicesForTypes.mockReturnValue(indices); + }); + + describe('with all extensions enabled', () => { + it('calls getCurrentNamespace with the correct parameters', async () => { + await performRemoveReferencesTo( + { type: 'foo', id: 'id', options: { namespace } }, + apiExecutionContext + ); + + const commonHelper = apiExecutionContext.helpers.common; + expect(commonHelper.getCurrentNamespace).toHaveBeenCalledTimes(1); + expect(commonHelper.getCurrentNamespace).toHaveBeenLastCalledWith(namespace); + }); + + it('calls authorizeRemoveReferences with the correct parameters', async () => { + await performRemoveReferencesTo( + { type: 'foo', id: 'id', options: { namespace } }, + apiExecutionContext + ); + + const securityExt = apiExecutionContext.extensions.securityExtension!; + expect(securityExt.authorizeRemoveReferences).toHaveBeenCalledTimes(1); + expect(securityExt.authorizeRemoveReferences).toHaveBeenLastCalledWith({ + namespace, + object: { type: 'foo', id: 'id' }, + }); + }); + + it('calls client.updateByQuery with the correct parameters', async () => { + await performRemoveReferencesTo( + { type: 'foo', id: 'id', options: { namespace, refresh: false } }, + apiExecutionContext + ); + + const client = apiExecutionContext.client; + expect(client.updateByQuery).toHaveBeenCalledTimes(1); + expect(client.updateByQuery).toHaveBeenLastCalledWith( + { + refresh: false, + index: indices, + body: expect.any(Object), + }, + { ignore: [404], meta: true } + ); + }); + }); +}); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/remove_references_to.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/remove_references_to.ts new file mode 100644 index 00000000000000..5b3117a0cd52f9 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/remove_references_to.ts @@ -0,0 +1,90 @@ +/* + * 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 { isNotFoundFromUnsupportedServer } from '@kbn/core-elasticsearch-server-internal'; +import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import { + SavedObjectsRemoveReferencesToOptions, + SavedObjectsRemoveReferencesToResponse, +} from '@kbn/core-saved-objects-api-server'; +import { ApiExecutionContext } from './types'; +import { getSearchDsl } from '../search_dsl'; + +export interface PerformRemoveReferencesToParams { + type: string; + id: string; + options: SavedObjectsRemoveReferencesToOptions; +} + +export const performRemoveReferencesTo = async ( + { type, id, options }: PerformRemoveReferencesToParams, + { registry, helpers, client, mappings, extensions = {} }: ApiExecutionContext +): Promise => { + const { common: commonHelper } = helpers; + const { securityExtension } = extensions; + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + const { refresh = true } = options; + + await securityExtension?.authorizeRemoveReferences({ namespace, object: { type, id } }); + + const allTypes = registry.getAllTypes().map((t) => t.name); + + // we need to target all SO indices as all types of objects may have references to the given SO. + const targetIndices = commonHelper.getIndicesForTypes(allTypes); + + const { body, statusCode, headers } = await client.updateByQuery( + { + index: targetIndices, + refresh, + body: { + script: { + source: ` + if (ctx._source.containsKey('references')) { + def items_to_remove = []; + for (item in ctx._source.references) { + if ( (item['type'] == params['type']) && (item['id'] == params['id']) ) { + items_to_remove.add(item); + } + } + ctx._source.references.removeAll(items_to_remove); + } + `, + params: { + type, + id, + }, + lang: 'painless', + }, + conflicts: 'proceed', + ...getSearchDsl(mappings, registry, { + namespaces: namespace ? [namespace] : undefined, + type: allTypes, + hasReference: { type, id }, + }), + }, + }, + { ignore: [404], meta: true } + ); + // fail fast if we can't verify a 404 is from Elasticsearch + if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); + } + + if (body.failures?.length) { + throw SavedObjectsErrorHelpers.createConflictError( + type, + id, + `${body.failures.length} references could not be removed` + ); + } + + return { + updated: body.updated!, + }; +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/resolve.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/resolve.ts new file mode 100644 index 00000000000000..884afc573262d7 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/resolve.ts @@ -0,0 +1,59 @@ +/* + * 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 { + SavedObjectsResolveOptions, + SavedObjectsResolveResponse, +} from '@kbn/core-saved-objects-api-server'; +import { ApiExecutionContext } from './types'; +import { internalBulkResolve, isBulkResolveError } from './internals/internal_bulk_resolve'; +import { incrementCounterInternal } from './internals/increment_counter_internal'; + +export interface PerformCreateParams { + type: string; + id: string; + options: SavedObjectsResolveOptions; +} + +export const performResolve = async ( + { type, id, options }: PerformCreateParams, + apiExecutionContext: ApiExecutionContext +): Promise> => { + const { + registry, + helpers, + allowedTypes, + client, + serializer, + extensions = {}, + } = apiExecutionContext; + const { common: commonHelper } = helpers; + const { securityExtension, encryptionExtension } = extensions; + const namespace = commonHelper.getCurrentNamespace(options.namespace); + const { resolved_objects: bulkResults } = await internalBulkResolve({ + registry, + allowedTypes, + client, + serializer, + getIndexForType: commonHelper.getIndexForType.bind(commonHelper), + incrementCounterInternal: (t, i, counterFields, opts = {}) => + incrementCounterInternal( + { type: t, id: i, counterFields, options: opts }, + apiExecutionContext + ), + encryptionExtension, + securityExtension, + objects: [{ type, id }], + options: { ...options, namespace }, + }); + const [result] = bulkResults; + if (isBulkResolveError(result)) { + throw result.error; + } + return result; +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/types.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/types.ts new file mode 100644 index 00000000000000..bfa07e4bf5dcc1 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/types.ts @@ -0,0 +1,29 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import type { + ISavedObjectTypeRegistry, + SavedObjectsExtensions, + ISavedObjectsSerializer, +} from '@kbn/core-saved-objects-server'; +import type { IKibanaMigrator, IndexMapping } from '@kbn/core-saved-objects-base-server-internal'; +import type { RepositoryHelpers } from './helpers'; +import type { RepositoryEsClient } from '../repository_es_client'; + +export interface ApiExecutionContext { + registry: ISavedObjectTypeRegistry; + helpers: RepositoryHelpers; + extensions: SavedObjectsExtensions; + client: RepositoryEsClient; + allowedTypes: string[]; + serializer: ISavedObjectsSerializer; + migrator: IKibanaMigrator; + logger: Logger; + mappings: IndexMapping; +} diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts new file mode 100644 index 00000000000000..eceb738ac7ae98 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts @@ -0,0 +1,179 @@ +/* + * 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 { + SavedObjectsErrorHelpers, + type SavedObject, + type SavedObjectSanitizedDoc, + SavedObjectsRawDoc, + SavedObjectsRawDocSource, +} from '@kbn/core-saved-objects-server'; +import { SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; +import { encodeHitVersion } from '@kbn/core-saved-objects-base-server-internal'; +import { + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, +} from '@kbn/core-saved-objects-api-server'; +import { DEFAULT_REFRESH_SETTING, DEFAULT_RETRY_COUNT } from '../constants'; +import { getCurrentTime, getExpectedVersionProperties } from './utils'; +import { ApiExecutionContext } from './types'; +import { PreflightCheckNamespacesResult } from './helpers'; + +export interface PerformUpdateParams { + type: string; + id: string; + attributes: T; + options: SavedObjectsUpdateOptions; +} + +export const performUpdate = async ( + { id, type, attributes, options }: PerformUpdateParams, + { + registry, + helpers, + allowedTypes, + client, + serializer, + migrator, + extensions = {}, + }: ApiExecutionContext +): Promise> => { + const { + common: commonHelper, + encryption: encryptionHelper, + preflight: preflightHelper, + } = helpers; + const { securityExtension } = extensions; + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + if (!allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + if (!id) { + throw SavedObjectsErrorHelpers.createBadRequestError('id cannot be empty'); // prevent potentially upserting a saved object with an empty ID + } + + const { + version, + references, + upsert, + refresh = DEFAULT_REFRESH_SETTING, + retryOnConflict = version ? 0 : DEFAULT_RETRY_COUNT, + } = options; + + let preflightResult: PreflightCheckNamespacesResult | undefined; + if (registry.isMultiNamespace(type)) { + preflightResult = await preflightHelper.preflightCheckNamespaces({ + type, + id, + namespace, + }); + } + + const existingNamespaces = preflightResult?.savedObjectNamespaces ?? []; + + const authorizationResult = await securityExtension?.authorizeUpdate({ + namespace, + object: { type, id, existingNamespaces }, + }); + + if ( + preflightResult?.checkResult === 'found_outside_namespace' || + (!upsert && preflightResult?.checkResult === 'not_found') + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + if (upsert && preflightResult?.checkResult === 'not_found') { + // If an upsert would result in the creation of a new object, we need to check for alias conflicts too. + // This takes an extra round trip to Elasticsearch, but this won't happen often. + // TODO: improve performance by combining these into a single preflight check + await preflightHelper.preflightCheckForUpsertAliasConflict(type, id, namespace); + } + const time = getCurrentTime(); + + let rawUpsert: SavedObjectsRawDoc | undefined; + // don't include upsert if the object already exists; ES doesn't allow upsert in combination with version properties + if (upsert && (!preflightResult || preflightResult.checkResult === 'not_found')) { + let savedObjectNamespace: string | undefined; + let savedObjectNamespaces: string[] | undefined; + + if (registry.isSingleNamespace(type) && namespace) { + savedObjectNamespace = namespace; + } else if (registry.isMultiNamespace(type)) { + savedObjectNamespaces = preflightResult!.savedObjectNamespaces; + } + + const migrated = migrator.migrateDocument({ + id, + type, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + attributes: { + ...(await encryptionHelper.optionallyEncryptAttributes(type, id, namespace, upsert)), + }, + updated_at: time, + }); + rawUpsert = serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); + } + + const doc = { + [type]: await encryptionHelper.optionallyEncryptAttributes(type, id, namespace, attributes), + updated_at: time, + ...(Array.isArray(references) && { references }), + }; + + const body = await client + .update({ + id: serializer.generateRawId(namespace, type, id), + index: commonHelper.getIndexForType(type), + ...getExpectedVersionProperties(version), + refresh, + retry_on_conflict: retryOnConflict, + body: { + doc, + ...(rawUpsert && { upsert: rawUpsert._source }), + }, + _source_includes: ['namespace', 'namespaces', 'originId'], + require_alias: true, + }) + .catch((err) => { + if (SavedObjectsErrorHelpers.isEsUnavailableError(err)) { + throw err; + } + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + throw err; + }); + + const { originId } = body.get?._source ?? {}; + let namespaces: string[] = []; + if (!registry.isNamespaceAgnostic(type)) { + namespaces = body.get?._source.namespaces ?? [ + SavedObjectsUtils.namespaceIdToString(body.get?._source.namespace), + ]; + } + + const result = { + id, + type, + updated_at: time, + version: encodeHitVersion(body), + namespaces, + ...(originId && { originId }), + references, + attributes, + } as SavedObject; + + return encryptionHelper.optionallyDecryptAndRedactSingleResult( + result, + authorizationResult?.typeMap, + attributes + ); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update_objects_spaces.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update_objects_spaces.ts new file mode 100644 index 00000000000000..d90d4fc9403ebe --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update_objects_spaces.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { + SavedObjectsUpdateObjectsSpacesObject, + SavedObjectsUpdateObjectsSpacesOptions, + SavedObjectsUpdateObjectsSpacesResponse, +} from '@kbn/core-saved-objects-api-server'; +import { ApiExecutionContext } from './types'; +import { updateObjectsSpaces } from './internals/update_objects_spaces'; + +export interface PerformCreateParams { + objects: SavedObjectsUpdateObjectsSpacesObject[]; + spacesToAdd: string[]; + spacesToRemove: string[]; + options: SavedObjectsUpdateObjectsSpacesOptions; +} + +export const performUpdateObjectsSpaces = async ( + { objects, spacesToAdd, spacesToRemove, options }: PerformCreateParams, + { + registry, + helpers, + allowedTypes, + client, + serializer, + logger, + mappings, + extensions = {}, + }: ApiExecutionContext +): Promise => { + const { common: commonHelper } = helpers; + const { securityExtension } = extensions; + + const namespace = commonHelper.getCurrentNamespace(options.namespace); + return updateObjectsSpaces({ + mappings, + registry, + allowedTypes, + client, + serializer, + logger, + getIndexForType: commonHelper.getIndexForType.bind(commonHelper), + securityExtension, + objects, + spacesToAdd, + spacesToRemove, + options: { ...options, namespace }, + }); +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/either.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/either.ts new file mode 100644 index 00000000000000..9403945a9a650d --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/either.ts @@ -0,0 +1,60 @@ +/* + * 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. + */ + +/** + * Discriminated union (TypeScript approximation of an algebraic data type); this design pattern is used for internal repository operations. + * @internal + */ +export type Either = Left | Right; + +/** + * Left part of discriminated union ({@link Either}). + * @internal + */ +export interface Left { + tag: 'Left'; + value: L; +} + +/** + * Right part of discriminated union ({@link Either}). + * @internal + */ +export interface Right { + tag: 'Right'; + value: R; +} + +/** + * Returns a {@link Left} part holding the provided value. + * @internal + */ +export const left = (value: L): Left => ({ + tag: 'Left', + value, +}); + +/** + * Returns a {@link Right} part holding the provided value. + * @internal + */ +export const right = (value: R): Right => ({ + tag: 'Right', + value, +}); + +/** + * Type guard for left part of discriminated union ({@link Left}, {@link Either}). + * @internal + */ +export const isLeft = (either: Either): either is Left => either.tag === 'Left'; +/** + * Type guard for right part of discriminated union ({@link Right}, {@link Either}). + * @internal + */ +export const isRight = (either: Either): either is Right => either.tag === 'Right'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/es_responses.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/es_responses.ts new file mode 100644 index 00000000000000..01f7dc3cd79a78 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/es_responses.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +/** + * Type and type guard function for converting a possibly not existent doc to an existent doc. + */ +export type GetResponseFound = estypes.GetResponse & + Required< + Pick, '_primary_term' | '_seq_no' | '_version' | '_source'> + >; + +export const isFoundGetResponse = ( + doc: estypes.GetResponse +): doc is GetResponseFound => doc.found; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/find_shared_origin_objects.test.ts similarity index 97% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.test.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/find_shared_origin_objects.test.ts index 44c81bc6eb49f5..c9f90073da24f7 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/find_shared_origin_objects.test.ts @@ -7,8 +7,8 @@ */ import { DeeplyMockedKeys } from '@kbn/utility-types-jest'; -import { CreatePointInTimeFinderFn, PointInTimeFinder } from './point_in_time_finder'; -import { savedObjectsPointInTimeFinderMock } from '../mocks/point_in_time_finder.mock'; +import { savedObjectsPointInTimeFinderMock } from '../../../mocks/point_in_time_finder.mock'; +import { CreatePointInTimeFinderFn, PointInTimeFinder } from '../../point_in_time_finder'; import { findSharedOriginObjects } from './find_shared_origin_objects'; import { SavedObjectsPointInTimeFinderClient } from '@kbn/core-saved-objects-api-server'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/find_shared_origin_objects.ts similarity index 98% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/find_shared_origin_objects.ts index a489e4afa91c37..8ed1f0a3c965af 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/find_shared_origin_objects.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/find_shared_origin_objects.ts @@ -9,7 +9,7 @@ import * as esKuery from '@kbn/es-query'; import { ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; import { getObjectKey } from '@kbn/core-saved-objects-base-server-internal'; -import type { CreatePointInTimeFinderFn } from './point_in_time_finder'; +import type { CreatePointInTimeFinderFn } from '../../point_in_time_finder'; interface ObjectOrigin { /** The object's type. */ diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/index.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/index.ts new file mode 100644 index 00000000000000..f3562dffb1e86b --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +export { isFoundGetResponse, type GetResponseFound } from './es_responses'; +export { findSharedOriginObjects } from './find_shared_origin_objects'; +export { + rawDocExistsInNamespace, + errorContent, + rawDocExistsInNamespaces, + isMgetDoc, + getCurrentTime, + getBulkOperationError, + getExpectedVersionProperties, + getSavedObjectFromSource, + setManaged, + normalizeNamespace, + getSavedObjectNamespaces, + type GetSavedObjectFromSourceOptions, +} from './internal_utils'; +export { type Left, type Either, type Right, isLeft, isRight, left, right } from './either'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_utils.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.test.ts similarity index 100% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_utils.test.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.test.ts diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_utils.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.ts similarity index 87% rename from packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_utils.ts rename to packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.ts index 1ffc5fcde62d86..f06894bcca1e5f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/internal_utils.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/utils/internal_utils.ts @@ -6,14 +6,16 @@ * Side Public License, v 1. */ +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { Payload } from '@hapi/boom'; import { + SavedObjectsErrorHelpers, type ISavedObjectTypeRegistry, type SavedObjectsRawDoc, type SavedObjectsRawDocSource, type SavedObject, - SavedObjectsErrorHelpers, type SavedObjectsRawDocParseOptions, + type DecoratedError, } from '@kbn/core-saved-objects-server'; import { SavedObjectsUtils, ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; import { @@ -21,41 +23,6 @@ import { encodeHitVersion, } from '@kbn/core-saved-objects-base-server-internal'; -/** - * Discriminated union (TypeScript approximation of an algebraic data type); this design pattern is used for internal repository operations. - * @internal - */ -export type Either = Left | Right; - -/** - * Left part of discriminated union ({@link Either}). - * @internal - */ -export interface Left { - tag: 'Left'; - value: L; -} - -/** - * Right part of discriminated union ({@link Either}). - * @internal - */ -export interface Right { - tag: 'Right'; - value: R; -} - -/** - * Type guard for left part of discriminated union ({@link Left}, {@link Either}). - * @internal - */ -export const isLeft = (either: Either): either is Left => either.tag === 'Left'; -/** - * Type guard for right part of discriminated union ({@link Right}, {@link Either}). - * @internal - */ -export const isRight = (either: Either): either is Right => either.tag === 'Right'; - /** * Checks the raw response of a bulk operation and returns an error if necessary. * @@ -295,3 +262,30 @@ export function setManaged({ }): boolean { return optionsManaged ?? objectManaged ?? false; } + +/** + * Returns a string array of namespaces for a given saved object. If the saved object is undefined, the result is an array that contains the + * current namespace. Value may be undefined if an existing saved object has no namespaces attribute; this should not happen in normal + * operations, but it is possible if the Elasticsearch document is manually modified. + * + * @param namespace The current namespace. + * @param document Optional existing saved object that was obtained in a preflight operation. + */ +export function getSavedObjectNamespaces( + namespace?: string, + document?: SavedObjectsRawDoc +): string[] | undefined { + if (document) { + return document._source?.namespaces; + } + return [SavedObjectsUtils.namespaceIdToString(namespace)]; +} + +/** + * Extracts the contents of a decorated error to return the attributes for bulk operations. + */ +export const errorContent = (error: DecoratedError) => error.output.payload; + +export function isMgetDoc(doc?: estypes.MgetResponseItem): doc is estypes.GetGetResult { + return Boolean(doc && 'found' in doc); +} diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/constants.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/constants.ts new file mode 100644 index 00000000000000..3b429a2a7dfa6e --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/constants.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export const DEFAULT_REFRESH_SETTING = 'wait_for'; +export const DEFAULT_RETRY_COUNT = 3; +export const MAX_CONCURRENT_ALIAS_DELETIONS = 10; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/priority_collection.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/priority_collection.test.ts deleted file mode 100644 index 300dc1349b59d0..00000000000000 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/priority_collection.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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 { PriorityCollection } from './priority_collection'; - -test(`1, 2, 3`, () => { - const priorityCollection = new PriorityCollection(); - priorityCollection.add(1, 1); - priorityCollection.add(2, 2); - priorityCollection.add(3, 3); - expect(priorityCollection.toPrioritizedArray()).toEqual([1, 2, 3]); -}); - -test(`3, 2, 1`, () => { - const priorityCollection = new PriorityCollection(); - priorityCollection.add(3, 3); - priorityCollection.add(2, 2); - priorityCollection.add(1, 1); - expect(priorityCollection.toPrioritizedArray()).toEqual([1, 2, 3]); -}); - -test(`2, 3, 1`, () => { - const priorityCollection = new PriorityCollection(); - priorityCollection.add(2, 2); - priorityCollection.add(3, 3); - priorityCollection.add(1, 1); - expect(priorityCollection.toPrioritizedArray()).toEqual([1, 2, 3]); -}); - -test(`Number.MAX_VALUE, NUMBER.MIN_VALUE, 1`, () => { - const priorityCollection = new PriorityCollection(); - priorityCollection.add(Number.MAX_VALUE, 3); - priorityCollection.add(Number.MIN_VALUE, 1); - priorityCollection.add(1, 2); - expect(priorityCollection.toPrioritizedArray()).toEqual([1, 2, 3]); -}); - -test(`1, 1 throws Error`, () => { - const priorityCollection = new PriorityCollection(); - priorityCollection.add(1, 1); - expect(() => priorityCollection.add(1, 1)).toThrowErrorMatchingSnapshot(); -}); - -test(`#has when empty returns false`, () => { - const priorityCollection = new PriorityCollection(); - expect(priorityCollection.has(() => true)).toEqual(false); -}); - -test(`#has returns result of predicate`, () => { - const priorityCollection = new PriorityCollection(); - priorityCollection.add(1, 'foo'); - expect(priorityCollection.has((val) => val === 'foo')).toEqual(true); - expect(priorityCollection.has((val) => val === 'bar')).toEqual(false); -}); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/priority_collection.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/priority_collection.ts deleted file mode 100644 index 66751101dd8b72..00000000000000 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/priority_collection.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 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. - */ - -interface PriorityCollectionEntry { - priority: number; - value: T; -} - -export class PriorityCollection { - private readonly array: Array> = []; - - public add(priority: number, value: T) { - const foundIndex = this.array.findIndex((current) => { - if (priority === current.priority) { - throw new Error('Already have entry with this priority'); - } - - return priority < current.priority; - }); - - const spliceIndex = foundIndex === -1 ? this.array.length : foundIndex; - this.array.splice(spliceIndex, 0, { priority, value }); - } - - public has(predicate: (value: T) => boolean): boolean { - return this.array.some((entry) => predicate(entry.value)); - } - - public toPrioritizedArray(): T[] { - return this.array.map((entry) => entry.value); - } -} diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.mock.ts index a9c1871e2488e1..6a2cfa16e24701 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.mock.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.mock.ts @@ -6,25 +6,25 @@ * Side Public License, v 1. */ -import type { collectMultiNamespaceReferences } from './collect_multi_namespace_references'; -import type { internalBulkResolve } from './internal_bulk_resolve'; -import type * as InternalUtils from './internal_utils'; -import type { preflightCheckForCreate } from './preflight_check_for_create'; -import type { updateObjectsSpaces } from './update_objects_spaces'; +import type { collectMultiNamespaceReferences } from './apis/internals/collect_multi_namespace_references'; +import type { internalBulkResolve } from './apis/internals/internal_bulk_resolve'; +import type * as InternalUtils from './apis/utils/internal_utils'; +import type { preflightCheckForCreate } from './apis/internals/preflight_check_for_create'; +import type { updateObjectsSpaces } from './apis/internals/update_objects_spaces'; import type { deleteLegacyUrlAliases } from './legacy_url_aliases'; export const mockCollectMultiNamespaceReferences = jest.fn() as jest.MockedFunction< typeof collectMultiNamespaceReferences >; -jest.mock('./collect_multi_namespace_references', () => ({ +jest.mock('./apis/internals/collect_multi_namespace_references', () => ({ collectMultiNamespaceReferences: mockCollectMultiNamespaceReferences, })); export const mockInternalBulkResolve = jest.fn() as jest.MockedFunction; -jest.mock('./internal_bulk_resolve', () => ({ - ...jest.requireActual('./internal_bulk_resolve'), +jest.mock('./apis/internals/internal_bulk_resolve', () => ({ + ...jest.requireActual('./apis/internals/internal_bulk_resolve'), internalBulkResolve: mockInternalBulkResolve, })); @@ -35,8 +35,8 @@ export const mockGetCurrentTime = jest.fn() as jest.MockedFunction< typeof InternalUtils['getCurrentTime'] >; -jest.mock('./internal_utils', () => { - const actual = jest.requireActual('./internal_utils'); +jest.mock('./apis/utils/internal_utils', () => { + const actual = jest.requireActual('./apis/utils/internal_utils'); return { ...actual, getBulkOperationError: mockGetBulkOperationError, @@ -48,13 +48,13 @@ export const mockPreflightCheckForCreate = jest.fn() as jest.MockedFunction< typeof preflightCheckForCreate >; -jest.mock('./preflight_check_for_create', () => ({ +jest.mock('./apis/internals/preflight_check_for_create', () => ({ preflightCheckForCreate: mockPreflightCheckForCreate, })); export const mockUpdateObjectsSpaces = jest.fn() as jest.MockedFunction; -jest.mock('./update_objects_spaces', () => ({ +jest.mock('./apis/internals/update_objects_spaces', () => ({ updateObjectsSpaces: mockUpdateObjectsSpaces, })); 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 3a0ff953dbff5d..409138cd635de0 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 @@ -6,21 +6,8 @@ * Side Public License, v 1. */ -import { omit, isObject } from 'lodash'; -import Boom from '@hapi/boom'; -import type { Payload } from '@hapi/boom'; -import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import * as esKuery from '@kbn/es-query'; import type { Logger } from '@kbn/logging'; import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; -import { - isSupportedEsServer, - isNotFoundFromUnsupportedServer, -} from '@kbn/core-elasticsearch-server-internal'; -import type { - BulkResolveError, - SavedObjectsRawDocParseOptions, -} from '@kbn/core-saved-objects-server'; import type { SavedObjectsBaseOptions, SavedObjectsIncrementCounterOptions, @@ -41,7 +28,6 @@ import type { SavedObjectsCheckConflictsObject, SavedObjectsCheckConflictsResponse, SavedObjectsBulkUpdateOptions, - SavedObjectsFindResult, SavedObjectsRemoveReferencesToOptions, SavedObjectsDeleteOptions, SavedObjectsOpenPointInTimeResponse, @@ -53,6 +39,7 @@ import type { SavedObjectsResolveResponse, SavedObjectsCollectMultiNamespaceReferencesObject, SavedObjectsUpdateObjectsSpacesObject, + SavedObjectsUpdateObjectsSpacesResponse, SavedObjectsUpdateOptions, SavedObjectsOpenPointInTimeOptions, SavedObjectsClosePointInTimeOptions, @@ -65,85 +52,52 @@ import type { SavedObjectsFindInternalOptions, ISavedObjectsRepository, } from '@kbn/core-saved-objects-api-server'; -import { - type SavedObjectSanitizedDoc, - type SavedObjectsRawDoc, - type SavedObjectsRawDocSource, - type ISavedObjectTypeRegistry, - type SavedObjectsExtensions, - type ISavedObjectsEncryptionExtension, - type ISavedObjectsSecurityExtension, - type ISavedObjectsSpacesExtension, - type CheckAuthorizationResult, - type AuthorizationTypeMap, - AuthorizeCreateObject, - AuthorizeUpdateObject, - type AuthorizeBulkGetObject, - type SavedObject, +import type { + ISavedObjectTypeRegistry, + SavedObjectsExtensions, + SavedObject, } from '@kbn/core-saved-objects-server'; -import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; -import { SavedObjectsErrorHelpers, type DecoratedError } from '@kbn/core-saved-objects-server'; -import { - ALL_NAMESPACES_STRING, - FIND_DEFAULT_PAGE, - FIND_DEFAULT_PER_PAGE, - SavedObjectsUtils, -} from '@kbn/core-saved-objects-utils-server'; import { SavedObjectsSerializer, - SavedObjectsTypeValidator, - decodeRequestVersion, - encodeVersion, - encodeHitVersion, - getRootPropertiesObjects, - LEGACY_URL_ALIAS_TYPE, - getIndexForType, 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, type RepositoryEsClient } from './repository_es_client'; -import { getSearchDsl } from './search_dsl'; -import { includedFields } from './included_fields'; -import { internalBulkResolve, isBulkResolveError } from './internal_bulk_resolve'; -import { validateConvertFilterToKueryNode } from './filter_utils'; -import { validateAndConvertAggregations } from './aggregations'; import { - getBulkOperationError, - getCurrentTime, - getExpectedVersionProperties, - getSavedObjectFromSource, - normalizeNamespace, - rawDocExistsInNamespace, - rawDocExistsInNamespaces, - type Either, - isLeft, - isRight, - setManaged, -} from './internal_utils'; -import { collectMultiNamespaceReferences } from './collect_multi_namespace_references'; -import { updateObjectsSpaces } from './update_objects_spaces'; + RepositoryHelpers, + CommonHelper, + EncryptionHelper, + ValidationHelper, + PreflightCheckHelper, + SerializerHelper, +} from './apis/helpers'; import { - preflightCheckForCreate, - type PreflightCheckForCreateObject, - type PreflightCheckForCreateResult, -} 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. + type ApiExecutionContext, + performCreate, + performBulkCreate, + performDelete, + performCheckConflicts, + performBulkDelete, + performDeleteByNamespace, + performFind, + performBulkGet, + performGet, + performUpdate, + performBulkUpdate, + performRemoveReferencesTo, + performOpenPointInTime, + performIncrementCounter, + performBulkResolve, + performResolve, + performUpdateObjectsSpaces, + performCollectMultiNamespaceReferences, +} from './apis'; +/** + * Constructor options for {@link SavedObjectsRepository} + * @internal + */ export interface SavedObjectsRepositoryOptions { index: string; mappings: IndexMapping; @@ -156,71 +110,30 @@ export interface SavedObjectsRepositoryOptions { extensions?: SavedObjectsExtensions; } -export const DEFAULT_REFRESH_SETTING = 'wait_for'; -export const DEFAULT_RETRY_COUNT = 3; - -const MAX_CONCURRENT_ALIAS_DELETIONS = 10; - -/** - * @internal - */ -interface PreflightCheckNamespacesParams { - /** The object type to fetch */ - type: string; - /** The object ID to fetch */ - id: string; - /** The current space */ - namespace: string | undefined; - /** Optional; for an object that is being created, this specifies the initial namespace(s) it will exist in (overriding the current space) */ - initialNamespaces?: string[]; -} - -/** - * @internal - */ -interface PreflightCheckNamespacesResult { - /** If the object exists, and whether or not it exists in the current space */ - checkResult: 'not_found' | 'found_in_namespace' | 'found_outside_namespace'; - /** - * What namespace(s) the object should exist in, if it needs to be created; practically speaking, this will never be undefined if - * checkResult == not_found or checkResult == found_in_namespace - */ - savedObjectNamespaces?: string[]; - /** The source of the raw document, if the object already exists */ - rawDocSource?: GetResponseFound; -} - -function isMgetDoc(doc?: estypes.MgetResponseItem): doc is estypes.GetGetResult { - return Boolean(doc && 'found' in doc); -} - /** - * Saved Objects Respositiry - the client entry point for saved object manipulation. + * Saved Objects Repository - the client entry point for all saved object manipulation. * * The SOR calls the Elasticsearch client and leverages extension implementations to * support spaces, security, and encryption features. * - * @public + * @internal */ export class SavedObjectsRepository implements ISavedObjectsRepository { - private _migrator: IKibanaMigrator; - private _index: string; - private _mappings: IndexMapping; - private _registry: ISavedObjectTypeRegistry; - private _allowedTypes: string[]; - private typeValidatorMap: Record = {}; + private readonly migrator: IKibanaMigrator; + private readonly mappings: IndexMapping; + private readonly registry: ISavedObjectTypeRegistry; + private readonly allowedTypes: string[]; private readonly client: RepositoryEsClient; - private readonly _encryptionExtension?: ISavedObjectsEncryptionExtension; - private readonly _securityExtension?: ISavedObjectsSecurityExtension; - private readonly _spacesExtension?: ISavedObjectsSpacesExtension; - private _serializer: SavedObjectsSerializer; - private _logger: Logger; + private readonly serializer: SavedObjectsSerializer; + private readonly logger: Logger; + private readonly apiExecutionContext: ApiExecutionContext; + private readonly extensions: SavedObjectsExtensions; + private readonly helpers: RepositoryHelpers; /** * A factory function for creating SavedObjectRepository instances. * - * @internalRemarks - * Tests are located in ./repository_create_repository.test.ts + * @internalRemarks Tests are located in ./repository_create_repository.test.ts * * @internal */ @@ -239,7 +152,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { const allTypes = typeRegistry.getAllTypes().map((t) => t.name); const serializer = new SavedObjectsSerializer(typeRegistry); const visibleTypes = allTypes.filter((type) => !typeRegistry.isHidden(type)); - + const allowedTypes = [...new Set(visibleTypes.concat(includedHiddenTypes))]; const missingTypeMappings = includedHiddenTypes.filter((type) => !allTypes.includes(type)); if (missingTypeMappings.length > 0) { throw new Error( @@ -247,8 +160,6 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { ); } - const allowedTypes = [...new Set(visibleTypes.concat(includedHiddenTypes))]; - return new injectedConstructor({ index: indexName, migrator, @@ -272,30 +183,68 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { migrator, allowedTypes = [], logger, - extensions, + extensions = {}, } = options; - // It's important that we migrate documents / mark them as up-to-date - // prior to writing them to the index. Otherwise, we'll cause unnecessary - // index migrations to run at Kibana startup, and those will probably fail - // due to invalidly versioned documents in the index. - // - // The migrator performs double-duty, and validates the documents prior - // to returning them. - this._migrator = migrator; - this._index = index; - this._mappings = mappings; - this._registry = typeRegistry; - this.client = createRepositoryEsClient(client); if (allowedTypes.length === 0) { throw new Error('Empty or missing types for saved object repository!'); } - this._allowedTypes = allowedTypes; - this._serializer = serializer; - this._logger = logger; - this._encryptionExtension = extensions?.encryptionExtension; - this._securityExtension = extensions?.securityExtension; - this._spacesExtension = extensions?.spacesExtension; + + this.migrator = migrator; + this.mappings = mappings; + this.registry = typeRegistry; + this.client = createRepositoryEsClient(client); + this.allowedTypes = allowedTypes; + this.serializer = serializer; + this.logger = logger; + this.extensions = extensions; + + const commonHelper = new CommonHelper({ + spaceExtension: extensions?.spacesExtension, + encryptionExtension: extensions?.encryptionExtension, + createPointInTimeFinder: this.createPointInTimeFinder.bind(this), + defaultIndex: index, + kibanaVersion: migrator.kibanaVersion, + registry: typeRegistry, + }); + const encryptionHelper = new EncryptionHelper({ + encryptionExtension: extensions?.encryptionExtension, + securityExtension: extensions?.securityExtension, + }); + const validationHelper = new ValidationHelper({ + registry: typeRegistry, + logger, + kibanaVersion: migrator.kibanaVersion, + }); + const preflightCheckHelper = new PreflightCheckHelper({ + getIndexForType: commonHelper.getIndexForType.bind(commonHelper), + createPointInTimeFinder: commonHelper.createPointInTimeFinder.bind(commonHelper), + serializer, + registry: typeRegistry, + client: this.client, + }); + const serializerHelper = new SerializerHelper({ + registry: typeRegistry, + serializer, + }); + this.helpers = { + common: commonHelper, + preflight: preflightCheckHelper, + validation: validationHelper, + encryption: encryptionHelper, + serializer: serializerHelper, + }; + this.apiExecutionContext = { + client: this.client, + extensions: this.extensions, + helpers: this.helpers, + allowedTypes: this.allowedTypes, + registry: this.registry, + serializer: this.serializer, + migrator: this.migrator, + mappings: this.mappings, + logger: this.logger, + }; } /** @@ -306,131 +255,13 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { attributes: T, options: SavedObjectsCreateOptions = {} ): Promise> { - const namespace = this.getCurrentNamespace(options.namespace); - const { - migrationVersion, - coreMigrationVersion, - typeMigrationVersion, - managed, - overwrite = false, - references = [], - refresh = DEFAULT_REFRESH_SETTING, - initialNamespaces, - version, - } = options; - const { migrationVersionCompatibility } = options; - if (!this._allowedTypes.includes(type)) { - throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); - } - const id = this.getValidId(type, options.id, options.version, options.overwrite); - this.validateInitialNamespaces(type, initialNamespaces); - this.validateOriginId(type, options); - - const time = getCurrentTime(); - let savedObjectNamespace: string | undefined; - let savedObjectNamespaces: string[] | undefined; - let existingOriginId: string | undefined; - const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); - - let preflightResult: PreflightCheckForCreateResult | undefined; - if (this._registry.isSingleNamespace(type)) { - savedObjectNamespace = initialNamespaces - ? normalizeNamespace(initialNamespaces[0]) - : namespace; - } else if (this._registry.isMultiNamespace(type)) { - if (options.id) { - // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces - // note: this check throws an error if the object is found but does not exist in this namespace - preflightResult = ( - await preflightCheckForCreate({ - registry: this._registry, - client: this.client, - serializer: this._serializer, - getIndexForType: this.getIndexForType.bind(this), - createPointInTimeFinder: this.createPointInTimeFinder.bind(this), - objects: [{ type, id, overwrite, namespaces: initialNamespaces ?? [namespaceString] }], - }) - )[0]; - } - savedObjectNamespaces = - initialNamespaces || getSavedObjectNamespaces(namespace, preflightResult?.existingDocument); - existingOriginId = preflightResult?.existingDocument?._source?.originId; - } - - const authorizationResult = await this._securityExtension?.authorizeCreate({ - namespace, - object: { + return await performCreate( + { type, - id, - initialNamespaces, - existingNamespaces: preflightResult?.existingDocument?._source?.namespaces ?? [], + attributes, + options, }, - }); - - if (preflightResult?.error) { - // This intentionally occurs _after_ the authZ enforcement (which may throw a 403 error earlier) - throw SavedObjectsErrorHelpers.createConflictError(type, id); - } - - // 1. If the originId has been *explicitly set* in the options (defined or undefined), respect that. - // 2. Otherwise, preserve the originId of the existing object that is being overwritten, if any. - const originId = Object.keys(options).includes('originId') - ? options.originId - : existingOriginId; - const migrated = this._migrator.migrateDocument({ - id, - type, - ...(savedObjectNamespace && { namespace: savedObjectNamespace }), - ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), - originId, - attributes: await this.optionallyEncryptAttributes( - type, - id, - savedObjectNamespace, // if single namespace type, this is the first in initialNamespaces. If multi-namespace type this is options.namespace/current namespace. - attributes - ), - migrationVersion, - coreMigrationVersion, - typeMigrationVersion, - managed: setManaged({ optionsManaged: managed }), - created_at: time, - updated_at: time, - ...(Array.isArray(references) && { references }), - }); - - /** - * If a validation has been registered for this type, we run it against the migrated attributes. - * This is an imperfect solution because malformed attributes could have already caused the - * migration to fail, but it's the best we can do without devising a way to run validations - * inside the migration algorithm itself. - */ - this.validateObjectForCreate(type, migrated as SavedObjectSanitizedDoc); - - const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); - - const requestParams = { - id: raw._id, - index: this.getIndexForType(type), - refresh, - body: raw._source, - ...(overwrite && version ? decodeRequestVersion(version) : {}), - require_alias: true, - }; - - const { body, statusCode, headers } = - id && overwrite - ? await this.client.index(requestParams, { meta: true }) - : await this.client.create(requestParams, { meta: true }); - - // throw if we can't verify a 404 response is from Elasticsearch - if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(id, type); - } - - return this.optionallyDecryptAndRedactSingleResult( - this._rawToSavedObject({ ...raw, ...body }, { migrationVersionCompatibility }), - authorizationResult?.typeMap, - attributes + this.apiExecutionContext ); } @@ -441,263 +272,13 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: Array>, options: SavedObjectsCreateOptions = {} ): Promise> { - const namespace = this.getCurrentNamespace(options.namespace); - const { - migrationVersionCompatibility, - overwrite = false, - refresh = DEFAULT_REFRESH_SETTING, - managed: optionsManaged, - } = options; - const time = getCurrentTime(); - - let preflightCheckIndexCounter = 0; - type ExpectedResult = Either< - { type: string; id?: string; error: Payload }, + return await performBulkCreate( { - method: 'index' | 'create'; - object: SavedObjectsBulkCreateObject & { id: string }; - preflightCheckIndex?: number; - } - >; - const expectedResults = objects.map((object) => { - const { type, id: requestId, initialNamespaces, version, managed } = object; - let error: DecoratedError | undefined; - let id: string = ''; // Assign to make TS happy, the ID will be validated (or randomly generated if needed) during getValidId below - const objectManaged = managed; - if (!this._allowedTypes.includes(type)) { - error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); - } else { - try { - id = this.getValidId(type, requestId, version, overwrite); - this.validateInitialNamespaces(type, initialNamespaces); - this.validateOriginId(type, object); - } catch (e) { - error = e; - } - } - - if (error) { - return { - tag: 'Left', - value: { id: requestId, type, error: errorContent(error) }, - }; - } - - const method = requestId && overwrite ? 'index' : 'create'; - const requiresNamespacesCheck = requestId && this._registry.isMultiNamespace(type); - - return { - tag: 'Right', - value: { - method, - object: { - ...object, - id, - managed: setManaged({ optionsManaged, objectManaged }), - }, - ...(requiresNamespacesCheck && { preflightCheckIndex: preflightCheckIndexCounter++ }), - }, - }; - }); - - const validObjects = expectedResults.filter(isRight); - if (validObjects.length === 0) { - // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. - return { - // Technically the returned array should only contain SavedObject results, but for errors this is not true (we cast to 'unknown' below) - saved_objects: expectedResults.map>( - ({ value }) => value as unknown as SavedObject - ), - }; - } - - const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); - const preflightCheckObjects = validObjects - .filter(({ value }) => value.preflightCheckIndex !== undefined) - .map(({ value }) => { - const { type, id, initialNamespaces } = value.object; - const namespaces = initialNamespaces ?? [namespaceString]; - return { type, id, overwrite, namespaces }; - }); - const preflightCheckResponse = await preflightCheckForCreate({ - registry: this._registry, - client: this.client, - serializer: this._serializer, - getIndexForType: this.getIndexForType.bind(this), - createPointInTimeFinder: this.createPointInTimeFinder.bind(this), - objects: preflightCheckObjects, - }); - - const authObjects: AuthorizeCreateObject[] = validObjects.map((element) => { - const { object, preflightCheckIndex: index } = element.value; - const preflightResult = index !== undefined ? preflightCheckResponse[index] : undefined; - return { - type: object.type, - id: object.id, - initialNamespaces: object.initialNamespaces, - existingNamespaces: preflightResult?.existingDocument?._source.namespaces ?? [], - }; - }); - - const authorizationResult = await this._securityExtension?.authorizeBulkCreate({ - namespace, - objects: authObjects, - }); - - let bulkRequestIndexCounter = 0; - const bulkCreateParams: object[] = []; - type ExpectedBulkResult = Either< - { type: string; id?: string; error: Payload }, - { esRequestIndex: number; requestedId: string; rawMigratedDoc: SavedObjectsRawDoc } - >; - const expectedBulkResults = await Promise.all( - expectedResults.map>(async (expectedBulkGetResult) => { - if (isLeft(expectedBulkGetResult)) { - return expectedBulkGetResult; - } - - let savedObjectNamespace: string | undefined; - let savedObjectNamespaces: string[] | undefined; - let existingOriginId: string | undefined; - let versionProperties; - const { - preflightCheckIndex, - object: { initialNamespaces, version, ...object }, - method, - } = expectedBulkGetResult.value; - if (preflightCheckIndex !== undefined) { - const preflightResult = preflightCheckResponse[preflightCheckIndex]; - const { type, id, existingDocument, error } = preflightResult; - if (error) { - const { metadata } = error; - return { - tag: 'Left', - value: { - id, - type, - error: { - ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), - ...(metadata && { metadata }), - }, - }, - }; - } - savedObjectNamespaces = - initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); - versionProperties = getExpectedVersionProperties(version); - existingOriginId = existingDocument?._source?.originId; - } else { - if (this._registry.isSingleNamespace(object.type)) { - savedObjectNamespace = initialNamespaces - ? normalizeNamespace(initialNamespaces[0]) - : namespace; - } else if (this._registry.isMultiNamespace(object.type)) { - savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); - } - versionProperties = getExpectedVersionProperties(version); - } - - // 1. If the originId has been *explicitly set* in the options (defined or undefined), respect that. - // 2. Otherwise, preserve the originId of the existing object that is being overwritten, if any. - const originId = Object.keys(object).includes('originId') - ? object.originId - : existingOriginId; - const migrated = this._migrator.migrateDocument({ - id: object.id, - type: object.type, - attributes: await this.optionallyEncryptAttributes( - object.type, - object.id, - savedObjectNamespace, // only used for multi-namespace object types - object.attributes - ), - migrationVersion: object.migrationVersion, - coreMigrationVersion: object.coreMigrationVersion, - typeMigrationVersion: object.typeMigrationVersion, - ...(savedObjectNamespace && { namespace: savedObjectNamespace }), - ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), - managed: setManaged({ optionsManaged, objectManaged: object.managed }), - updated_at: time, - created_at: time, - references: object.references || [], - originId, - }) as SavedObjectSanitizedDoc; - - /** - * If a validation has been registered for this type, we run it against the migrated attributes. - * This is an imperfect solution because malformed attributes could have already caused the - * migration to fail, but it's the best we can do without devising a way to run validations - * inside the migration algorithm itself. - */ - try { - this.validateObjectForCreate(object.type, migrated); - } catch (error) { - return { - tag: 'Left', - value: { - id: object.id, - type: object.type, - error, - }, - }; - } - - const expectedResult = { - esRequestIndex: bulkRequestIndexCounter++, - requestedId: object.id, - rawMigratedDoc: this._serializer.savedObjectToRaw(migrated), - }; - - bulkCreateParams.push( - { - [method]: { - _id: expectedResult.rawMigratedDoc._id, - _index: this.getIndexForType(object.type), - ...(overwrite && versionProperties), - }, - }, - expectedResult.rawMigratedDoc._source - ); - - return { tag: 'Right', value: expectedResult }; - }) + objects, + options, + }, + this.apiExecutionContext ); - - const bulkResponse = bulkCreateParams.length - ? await this.client.bulk({ - refresh, - require_alias: true, - body: bulkCreateParams, - }) - : undefined; - - const result = { - saved_objects: expectedBulkResults.map((expectedResult) => { - if (isLeft(expectedResult)) { - return expectedResult.value as any; - } - - const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value; - const rawResponse = Object.values(bulkResponse?.items[esRequestIndex] ?? {})[0] as any; - - const error = getBulkOperationError(rawMigratedDoc._source.type, requestedId, rawResponse); - if (error) { - return { type: rawMigratedDoc._source.type, id: requestedId, error }; - } - - // When method == 'index' the bulkResponse doesn't include the indexed - // _source so we return rawMigratedDoc but have to spread the latest - // _seq_no and _primary_term values from the rawResponse. - return this._rawToSavedObject( - { - ...rawMigratedDoc, - ...{ _seq_no: rawResponse._seq_no, _primary_term: rawResponse._primary_term }, - }, - { migrationVersionCompatibility } - ); - }), - }; - return this.optionallyDecryptAndRedactBulkResult(result, authorizationResult?.typeMap, objects); } /** @@ -707,355 +288,29 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: SavedObjectsCheckConflictsObject[] = [], options: SavedObjectsBaseOptions = {} ): Promise { - const namespace = this.getCurrentNamespace(options.namespace); - - if (objects.length === 0) { - return { errors: [] }; - } - - let bulkGetRequestIndexCounter = 0; - type ExpectedBulkGetResult = Either< - { type: string; id: string; error: Payload }, - { type: string; id: string; esRequestIndex: number } - >; - const expectedBulkGetResults = objects.map((object) => { - const { type, id } = object; - - if (!this._allowedTypes.includes(type)) { - return { - tag: 'Left', - value: { - id, - type, - error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), - }, - }; - } - - return { - tag: 'Right', - value: { - type, - id, - esRequestIndex: bulkGetRequestIndexCounter++, - }, - }; - }); - - const validObjects = expectedBulkGetResults.filter(isRight); - await this._securityExtension?.authorizeCheckConflicts({ - namespace, - objects: validObjects.map((element) => ({ type: element.value.type, id: element.value.id })), - }); - - const bulkGetDocs = validObjects.map(({ value: { type, id } }) => ({ - _id: this._serializer.generateRawId(namespace, type, id), - _index: this.getIndexForType(type), - _source: { includes: ['type', 'namespaces'] }, - })); - const bulkGetResponse = bulkGetDocs.length - ? await this.client.mget( - { - body: { - docs: bulkGetDocs, - }, - }, - { ignore: [404], meta: true } - ) - : undefined; - // throw if we can't verify a 404 response is from Elasticsearch - if ( - bulkGetResponse && - isNotFoundFromUnsupportedServer({ - statusCode: bulkGetResponse.statusCode, - headers: bulkGetResponse.headers, - }) - ) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); - } - - const errors: SavedObjectsCheckConflictsResponse['errors'] = []; - expectedBulkGetResults.forEach((expectedResult) => { - if (isLeft(expectedResult)) { - errors.push(expectedResult.value as any); - return; - } - - const { type, id, esRequestIndex } = expectedResult.value; - const doc = bulkGetResponse?.body.docs[esRequestIndex]; - if (isMgetDoc(doc) && doc.found) { - errors.push({ - id, - type, - error: { - ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), - // @ts-expect-error MultiGetHit._source is optional - ...(!this.rawDocExistsInNamespace(doc!, namespace) && { - metadata: { isNotOverwritable: true }, - }), - }, - }); - } - }); - - return { errors }; + return await performCheckConflicts( + { + objects, + options, + }, + this.apiExecutionContext + ); } /** * {@inheritDoc ISavedObjectsRepository.delete} */ async delete(type: string, id: string, options: SavedObjectsDeleteOptions = {}): Promise<{}> { - const namespace = this.getCurrentNamespace(options.namespace); - - if (!this._allowedTypes.includes(type)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - - const { refresh = DEFAULT_REFRESH_SETTING, force } = options; - - // we don't need to pass existing namespaces in because we're only concerned with authorizing - // the current space. This saves us from performing the preflight check if we're unauthorized - await this._securityExtension?.authorizeDelete({ - namespace, - object: { type, id }, - }); - - const rawId = this._serializer.generateRawId(namespace, type, id); - let preflightResult: PreflightCheckNamespacesResult | undefined; - - if (this._registry.isMultiNamespace(type)) { - // note: this check throws an error if the object is found but does not exist in this namespace - preflightResult = await this.preflightCheckNamespaces({ - type, - id, - namespace, - }); - if ( - preflightResult.checkResult === 'found_outside_namespace' || - preflightResult.checkResult === 'not_found' - ) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - const existingNamespaces = preflightResult.savedObjectNamespaces ?? []; - if ( - !force && - (existingNamespaces.length > 1 || existingNamespaces.includes(ALL_NAMESPACES_STRING)) - ) { - throw SavedObjectsErrorHelpers.createBadRequestError( - 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' - ); - } - } - - const { body, statusCode, headers } = await this.client.delete( + return await performDelete( { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined), - refresh, - }, - { ignore: [404], meta: true } - ); - - if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); - } - - const deleted = body.result === 'deleted'; - if (deleted) { - const namespaces = preflightResult?.savedObjectNamespaces; - if (namespaces) { - // This is a multi-namespace object type, and it might have legacy URL aliases that need to be deleted. - await deleteLegacyUrlAliases({ - mappings: this._mappings, - registry: this._registry, - client: this.client, - getIndexForType: this.getIndexForType.bind(this), - type, - id, - ...(namespaces.includes(ALL_NAMESPACES_STRING) - ? { namespaces: [], deleteBehavior: 'exclusive' } // delete legacy URL aliases for this type/ID for all spaces - : { namespaces, deleteBehavior: 'inclusive' }), // delete legacy URL aliases for this type/ID for these specific spaces - }).catch((err) => { - // The object has already been deleted, but we caught an error when attempting to delete aliases. - // A consumer cannot attempt to delete the object again, so just log the error and swallow it. - this._logger.error(`Unable to delete aliases when deleting an object: ${err.message}`); - }); - } - return {}; - } - - const deleteDocNotFound = body.result === 'not_found'; - // @ts-expect-error @elastic/elasticsearch doesn't declare error on DeleteResponse - const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; - if (deleteDocNotFound || deleteIndexNotFound) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - - throw new Error( - `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ type, id, - response: { body, statusCode }, - })}` + options, + }, + this.apiExecutionContext ); } - /** - * 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; - // 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} */ @@ -1063,160 +318,13 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: SavedObjectsBulkDeleteObject[], options: SavedObjectsBulkDeleteOptions = {} ): Promise { - const { refresh = DEFAULT_REFRESH_SETTING, force } = options; - const namespace = this.getCurrentNamespace(options.namespace); - const expectedBulkGetResults = this.presortObjectsByNamespaceType(objects); - if (expectedBulkGetResults.length === 0) { - return { statuses: [] }; - } - - const multiNamespaceDocsResponse = await this.preflightCheckForBulkDelete({ - expectedBulkGetResults, - namespace, - }); - - // First round of filtering (Left: object doesn't exist/doesn't exist in namespace, Right: good to proceed) - const expectedBulkDeleteMultiNamespaceDocsResults = - this.getExpectedBulkDeleteMultiNamespaceDocsResults({ - expectedBulkGetResults, - multiNamespaceDocsResponse, - namespace, - force, - }); - - if (this._securityExtension) { - // Perform Auth Check (on both L/R, we'll deal with that later) - const authObjects: AuthorizeUpdateObject[] = expectedBulkDeleteMultiNamespaceDocsResults.map( - (element) => { - const index = (element.value as { esRequestIndex: number }).esRequestIndex; - const { type, id } = element.value; - const preflightResult = - index !== undefined ? multiNamespaceDocsResponse?.body.docs[index] : undefined; - - return { - type, - id, - // @ts-expect-error MultiGetHit._source is optional - existingNamespaces: preflightResult?._source?.namespaces ?? [], - }; - } - ); - await this._securityExtension.authorizeBulkDelete({ namespace, objects: authObjects }); - } - - // Filter valid objects - const validObjects = expectedBulkDeleteMultiNamespaceDocsResults.filter(isRight); - if (validObjects.length === 0) { - // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. - const savedObjects = expectedBulkDeleteMultiNamespaceDocsResults - .filter(isLeft) - .map((expectedResult) => { - return { ...expectedResult.value, success: false }; - }); - return { statuses: [...savedObjects] }; - } - - // Create the bulkDeleteParams - const bulkDeleteParams: BulkDeleteParams[] = []; - validObjects.map((expectedResult) => { - bulkDeleteParams.push({ - delete: { - _id: this._serializer.generateRawId( - namespace, - expectedResult.value.type, - expectedResult.value.id - ), - _index: this.getIndexForType(expectedResult.value.type), - ...getExpectedVersionProperties(undefined), - }, - }); - }); - - const bulkDeleteResponse = bulkDeleteParams.length - ? 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] }; + return await performBulkDelete( + { + objects, + options, + }, + this.apiExecutionContext + ); } /** @@ -1226,58 +334,13 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { namespace: string, options: SavedObjectsDeleteByNamespaceOptions = {} ): Promise { - // This is not exposed on the SOC; authorization and audit logging is handled by the Spaces plugin - if (!namespace || typeof namespace !== 'string' || namespace === '*') { - throw new TypeError(`namespace is required, and must be a string that is not equal to '*'`); - } - - const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); - const typesToUpdate = [ - ...allTypes.filter((type) => !this._registry.isNamespaceAgnostic(type)), - LEGACY_URL_ALIAS_TYPE, - ]; - - // Construct kueryNode to filter legacy URL aliases (these space-agnostic objects do not use root-level "namespace/s" fields) - const { buildNode } = esKuery.nodeTypes.function; - const match1 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.targetNamespace`, namespace); - const match2 = buildNode('not', buildNode('is', 'type', LEGACY_URL_ALIAS_TYPE)); - const kueryNode = buildNode('or', [match1, match2]); - - const { body, statusCode, headers } = await this.client.updateByQuery( + return await performDeleteByNamespace( { - index: this.getIndicesForTypes(typesToUpdate), - refresh: options.refresh, - body: { - script: { - source: ` - if (!ctx._source.containsKey('namespaces')) { - ctx.op = "delete"; - } else { - ctx._source['namespaces'].removeAll(Collections.singleton(params['namespace'])); - if (ctx._source['namespaces'].empty) { - ctx.op = "delete"; - } - } - `, - lang: 'painless', - params: { namespace }, - }, - conflicts: 'proceed', - ...getSearchDsl(this._mappings, this._registry, { - namespaces: [namespace], - type: typesToUpdate, - kueryNode, - }), - }, + namespace, + options, }, - { ignore: [404], meta: true } + this.apiExecutionContext ); - // throw if we can't verify a 404 response is from Elasticsearch - if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); - } - - return body; } /** @@ -1287,215 +350,12 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { options: SavedObjectsFindOptions, internalOptions: SavedObjectsFindInternalOptions = {} ): Promise> { - let namespaces!: string[]; - const { disableExtensions } = internalOptions; - if (disableExtensions || !this._spacesExtension) { - namespaces = options.namespaces ?? [DEFAULT_NAMESPACE_STRING]; - // If the consumer specified `namespaces: []`, throw a Bad Request error - if (namespaces.length === 0) - throw SavedObjectsErrorHelpers.createBadRequestError( - 'options.namespaces cannot be an empty array' - ); - } - - const { - search, - defaultSearchOperator = 'OR', - searchFields, - rootSearchFields, - hasReference, - hasReferenceOperator, - hasNoReference, - hasNoReferenceOperator, - page = FIND_DEFAULT_PAGE, - perPage = FIND_DEFAULT_PER_PAGE, - pit, - searchAfter, - sortField, - sortOrder, - fields, - type, - filter, - preference, - aggs, - migrationVersionCompatibility, - } = options; - - if (!type) { - throw SavedObjectsErrorHelpers.createBadRequestError( - 'options.type must be a string or an array of strings' - ); - } else if (preference?.length && pit) { - throw SavedObjectsErrorHelpers.createBadRequestError( - 'options.preference must be excluded when options.pit is used' - ); - } - - const types = Array.isArray(type) ? type : [type]; - const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); - if (allowedTypes.length === 0) { - return SavedObjectsUtils.createEmptyFindResponse(options); - } - - if (searchFields && !Array.isArray(searchFields)) { - throw SavedObjectsErrorHelpers.createBadRequestError('options.searchFields must be an array'); - } - - if (fields && !Array.isArray(fields)) { - throw SavedObjectsErrorHelpers.createBadRequestError('options.fields must be an array'); - } - - let kueryNode; - if (filter) { - try { - kueryNode = validateConvertFilterToKueryNode(allowedTypes, filter, this._mappings); - } catch (e) { - if (e.name === 'KQLSyntaxError') { - throw SavedObjectsErrorHelpers.createBadRequestError(`KQLSyntaxError: ${e.message}`); - } else { - throw e; - } - } - } - - let aggsObject; - if (aggs) { - try { - aggsObject = validateAndConvertAggregations(allowedTypes, aggs, this._mappings); - } catch (e) { - throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid aggregation: ${e.message}`); - } - } - - if (!disableExtensions && this._spacesExtension) { - try { - namespaces = await this._spacesExtension.getSearchableNamespaces(options.namespaces); - } catch (err) { - if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { - // The user is not authorized to access any space, return an empty response. - return SavedObjectsUtils.createEmptyFindResponse(options); - } - throw err; - } - if (namespaces.length === 0) { - // The user is authorized to access *at least one space*, but not any of the spaces they requested; return an empty response. - return SavedObjectsUtils.createEmptyFindResponse(options); - } - } - - // We have to first perform an initial authorization check so that we can construct the search DSL accordingly - const spacesToAuthorize = new Set(namespaces); - const typesToAuthorize = new Set(types); - let typeToNamespacesMap: Map | undefined; - let authorizationResult: CheckAuthorizationResult | undefined; - if (!disableExtensions && this._securityExtension) { - authorizationResult = await this._securityExtension.authorizeFind({ - namespaces: spacesToAuthorize, - types: typesToAuthorize, - }); - if (authorizationResult?.status === 'unauthorized') { - // If the user is unauthorized to find *anything* they requested, return an empty response - return SavedObjectsUtils.createEmptyFindResponse(options); - } - if (authorizationResult?.status === 'partially_authorized') { - typeToNamespacesMap = new Map(); - for (const [objType, entry] of authorizationResult.typeMap) { - if (!entry.find) continue; - // This ensures that the query DSL can filter only for object types that the user is authorized to access for a given space - const { authorizedSpaces, isGloballyAuthorized } = entry.find; - typeToNamespacesMap.set(objType, isGloballyAuthorized ? namespaces : authorizedSpaces); - } - } - } - - const esOptions = { - // If `pit` is provided, we drop the `index`, otherwise ES returns 400. - index: pit ? undefined : this.getIndicesForTypes(allowedTypes), - // If `searchAfter` is provided, we drop `from` as it will not be used for pagination. - from: searchAfter ? undefined : perPage * (page - 1), - _source: includedFields(allowedTypes, fields), - preference, - rest_total_hits_as_int: true, - size: perPage, - body: { - size: perPage, - seq_no_primary_term: true, - from: perPage * (page - 1), - _source: includedFields(allowedTypes, fields), - ...(aggsObject ? { aggs: aggsObject } : {}), - ...getSearchDsl(this._mappings, this._registry, { - search, - defaultSearchOperator, - searchFields, - pit, - rootSearchFields, - type: allowedTypes, - searchAfter, - sortField, - sortOrder, - namespaces, - typeToNamespacesMap, // If defined, this takes precedence over the `type` and `namespaces` fields - hasReference, - hasReferenceOperator, - hasNoReference, - hasNoReferenceOperator, - kueryNode, - }), - }, - }; - - const { body, statusCode, headers } = await this.client.search( - esOptions, + return await performFind( { - ignore: [404], - meta: true, - } - ); - if (statusCode === 404) { - if (!isSupportedEsServer(headers)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); - } - // 404 is only possible here if the index is missing, which - // we don't want to leak, see "404s from missing index" above - return SavedObjectsUtils.createEmptyFindResponse(options); - } - - const result = { - ...(body.aggregations ? { aggregations: body.aggregations as unknown as A } : {}), - page, - per_page: perPage, - total: body.hits.total, - saved_objects: body.hits.hits.map( - (hit: estypes.SearchHit): SavedObjectsFindResult => ({ - // @ts-expect-error @elastic/elasticsearch _source is optional - ...this._rawToSavedObject(hit, { migrationVersionCompatibility }), - score: hit._score!, - sort: hit.sort, - }) - ), - pit_id: body.pit_id, - } as SavedObjectsFindResponse; - - if (disableExtensions) { - return result; - } - - // Now that we have a full set of results with all existing namespaces for each object, - // we need an updated authorization type map to pass on to the redact method - const redactTypeMap = await this._securityExtension?.getFindRedactTypeMap({ - previouslyCheckedNamespaces: spacesToAuthorize, - objects: result.saved_objects.map((obj) => { - return { - type: obj.type, - id: obj.id, - existingNamespaces: obj.namespaces ?? [], - }; - }), - }); - - return this.optionallyDecryptAndRedactBulkResult( - result, - redactTypeMap ?? authorizationResult?.typeMap // If the redact type map is valid, use that one; otherwise, fall back to the authorization check + options, + internalOptions, + }, + this.apiExecutionContext ); } @@ -1506,166 +366,14 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: SavedObjectsBulkGetObject[] = [], options: SavedObjectsGetOptions = {} ): Promise> { - const namespace = this.getCurrentNamespace(options.namespace); - const { migrationVersionCompatibility } = options; - - if (objects.length === 0) { - return { saved_objects: [] }; - } - - let availableSpacesPromise: Promise | undefined; - const getAvailableSpaces = async (spacesExtension: ISavedObjectsSpacesExtension) => { - if (!availableSpacesPromise) { - availableSpacesPromise = spacesExtension - .getSearchableNamespaces([ALL_NAMESPACES_STRING]) - .catch((err) => { - if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { - // the user doesn't have access to any spaces; return the current space ID and allow the SOR authZ check to fail - return [SavedObjectsUtils.namespaceIdToString(namespace)]; - } else { - throw err; - } - }); - } - return availableSpacesPromise; - }; - - let bulkGetRequestIndexCounter = 0; - type ExpectedBulkGetResult = Either< - { type: string; id: string; error: Payload }, - { type: string; id: string; fields?: string[]; namespaces?: string[]; esRequestIndex: number } - >; - const expectedBulkGetResults = await Promise.all( - objects.map>(async (object) => { - const { type, id, fields } = object; - - let error: DecoratedError | undefined; - if (!this._allowedTypes.includes(type)) { - error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); - } else { - try { - this.validateObjectNamespaces(type, id, object.namespaces); - } catch (e) { - error = e; - } - } - - if (error) { - return { - tag: 'Left', - value: { id, type, error: errorContent(error) }, - }; - } - - let namespaces = object.namespaces; - if (this._spacesExtension && namespaces?.includes(ALL_NAMESPACES_STRING)) { - namespaces = await getAvailableSpaces(this._spacesExtension); - } - return { - tag: 'Right', - value: { - type, - id, - fields, - namespaces, - esRequestIndex: bulkGetRequestIndexCounter++, - }, - }; - }) + return await performBulkGet( + { + objects, + options, + }, + this.apiExecutionContext ); - - const validObjects = expectedBulkGetResults.filter(isRight); - if (validObjects.length === 0) { - // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. - return { - // Technically the returned array should only contain SavedObject results, but for errors this is not true (we cast to 'any' below) - saved_objects: expectedBulkGetResults.map>( - ({ value }) => value as unknown as SavedObject - ), - }; - } - - const getNamespaceId = (namespaces?: string[]) => - namespaces !== undefined ? SavedObjectsUtils.namespaceStringToId(namespaces[0]) : namespace; - const bulkGetDocs = validObjects.map(({ value: { type, id, fields, namespaces } }) => ({ - _id: this._serializer.generateRawId(getNamespaceId(namespaces), type, id), // the namespace prefix is only used for single-namespace object types - _index: this.getIndexForType(type), - _source: { includes: includedFields(type, fields) }, - })); - const bulkGetResponse = bulkGetDocs.length - ? await this.client.mget( - { - body: { - docs: bulkGetDocs, - }, - }, - { ignore: [404], meta: true } - ) - : undefined; - // fail fast if we can't verify a 404 is from Elasticsearch - if ( - bulkGetResponse && - isNotFoundFromUnsupportedServer({ - statusCode: bulkGetResponse.statusCode, - headers: bulkGetResponse.headers, - }) - ) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); - } - - const authObjects: AuthorizeBulkGetObject[] = []; - const result = { - saved_objects: expectedBulkGetResults.map((expectedResult) => { - if (isLeft(expectedResult)) { - const { type, id } = expectedResult.value; - authObjects.push({ type, id, existingNamespaces: [], error: true }); - return expectedResult.value as any; - } - - const { - type, - id, - // set to default namespaces value for `rawDocExistsInNamespaces` check below - namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)], - esRequestIndex, - } = expectedResult.value; - - const doc = bulkGetResponse?.body.docs[esRequestIndex]; - - // @ts-expect-error MultiGetHit._source is optional - const docNotFound = !doc?.found || !this.rawDocExistsInNamespaces(doc, namespaces); - - authObjects.push({ - type, - id, - objectNamespaces: namespaces, - // @ts-expect-error MultiGetHit._source is optional - existingNamespaces: doc?._source?.namespaces ?? [], - error: docNotFound, - }); - - if (docNotFound) { - return { - id, - type, - error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), - } as any as SavedObject; - } - - // @ts-expect-error MultiGetHit._source is optional - return getSavedObjectFromSource(this._registry, type, id, doc, { - migrationVersionCompatibility, - }); - }), - }; - - const authorizationResult = await this._securityExtension?.authorizeBulkGet({ - namespace, - objects: authObjects, - }); - - return this.optionallyDecryptAndRedactBulkResult(result, authorizationResult?.typeMap); - } + } /** * {@inheritDoc ISavedObjectsRepository.bulkResolve} @@ -1674,32 +382,13 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: SavedObjectsBulkResolveObject[], options: SavedObjectsResolveOptions = {} ): Promise> { - const namespace = this.getCurrentNamespace(options.namespace); - const { resolved_objects: bulkResults } = await internalBulkResolve({ - registry: this._registry, - allowedTypes: this._allowedTypes, - client: this.client, - serializer: this._serializer, - getIndexForType: this.getIndexForType.bind(this), - incrementCounterInternal: this.incrementCounterInternal.bind(this), - encryptionExtension: this._encryptionExtension, - securityExtension: this._securityExtension, - objects, - options: { ...options, namespace }, - }); - const resolvedObjects = bulkResults.map>((result) => { - // extract payloads from saved object errors - if (isBulkResolveError(result)) { - const errorResult = result as BulkResolveError; - const { type, id, error } = errorResult; - return { - saved_object: { type, id, error: errorContent(error) } as unknown as SavedObject, - outcome: 'exactMatch', - }; - } - return result; - }); - return { resolved_objects: resolvedObjects }; + return await performBulkResolve( + { + objects, + options, + }, + this.apiExecutionContext + ); } /** @@ -1710,48 +399,14 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { id: string, options: SavedObjectsGetOptions = {} ): Promise> { - const namespace = this.getCurrentNamespace(options.namespace); - const { migrationVersionCompatibility } = options; - - if (!this._allowedTypes.includes(type)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - const { body, statusCode, headers } = await this.client.get( + return await performGet( { - id: this._serializer.generateRawId(namespace, type, id), - index: this.getIndexForType(type), - }, - { ignore: [404], meta: true } - ); - const indexNotFound = statusCode === 404; - // check if we have the elasticsearch header when index is not found and, if we do, ensure it is from Elasticsearch - if (indexNotFound && !isSupportedEsServer(headers)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); - } - - const objectNotFound = - !isFoundGetResponse(body) || indexNotFound || !this.rawDocExistsInNamespace(body, namespace); - - const authorizationResult = await this._securityExtension?.authorizeGet({ - namespace, - object: { type, id, - existingNamespaces: body?._source?.namespaces ?? [], + options, }, - objectNotFound, - }); - - if (objectNotFound) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - - const result = getSavedObjectFromSource(this._registry, type, id, body, { - migrationVersionCompatibility, - }); - - return this.optionallyDecryptAndRedactSingleResult(result, authorizationResult?.typeMap); + this.apiExecutionContext + ); } /** @@ -1762,24 +417,14 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { id: string, options: SavedObjectsResolveOptions = {} ): Promise> { - const namespace = this.getCurrentNamespace(options.namespace); - const { resolved_objects: bulkResults } = await internalBulkResolve({ - registry: this._registry, - allowedTypes: this._allowedTypes, - client: this.client, - serializer: this._serializer, - getIndexForType: this.getIndexForType.bind(this), - incrementCounterInternal: this.incrementCounterInternal.bind(this), - encryptionExtension: this._encryptionExtension, - securityExtension: this._securityExtension, - objects: [{ type, id }], - options: { ...options, namespace }, - }); - const [result] = bulkResults; - if (isBulkResolveError(result)) { - throw result.error; - } - return result; + return await performResolve( + { + type, + id, + options, + }, + this.apiExecutionContext + ); } /** @@ -1791,132 +436,14 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { attributes: Partial, options: SavedObjectsUpdateOptions = {} ): Promise> { - const namespace = this.getCurrentNamespace(options.namespace); - - if (!this._allowedTypes.includes(type)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - if (!id) { - throw SavedObjectsErrorHelpers.createBadRequestError('id cannot be empty'); // prevent potentially upserting a saved object with an empty ID - } - - const { - version, - references, - upsert, - refresh = DEFAULT_REFRESH_SETTING, - retryOnConflict = version ? 0 : DEFAULT_RETRY_COUNT, - } = options; - - let preflightResult: PreflightCheckNamespacesResult | undefined; - if (this._registry.isMultiNamespace(type)) { - preflightResult = await this.preflightCheckNamespaces({ + return await performUpdate( + { type, id, - namespace, - }); - } - - const existingNamespaces = preflightResult?.savedObjectNamespaces ?? []; - - const authorizationResult = await this._securityExtension?.authorizeUpdate({ - namespace, - object: { type, id, existingNamespaces }, - }); - - if ( - preflightResult?.checkResult === 'found_outside_namespace' || - (!upsert && preflightResult?.checkResult === 'not_found') - ) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - if (upsert && preflightResult?.checkResult === 'not_found') { - // If an upsert would result in the creation of a new object, we need to check for alias conflicts too. - // This takes an extra round trip to Elasticsearch, but this won't happen often. - // TODO: improve performance by combining these into a single preflight check - await this.preflightCheckForUpsertAliasConflict(type, id, namespace); - } - const time = getCurrentTime(); - - let rawUpsert: SavedObjectsRawDoc | undefined; - // don't include upsert if the object already exists; ES doesn't allow upsert in combination with version properties - if (upsert && (!preflightResult || preflightResult.checkResult === 'not_found')) { - let savedObjectNamespace: string | undefined; - let savedObjectNamespaces: string[] | undefined; - - if (this._registry.isSingleNamespace(type) && namespace) { - savedObjectNamespace = namespace; - } else if (this._registry.isMultiNamespace(type)) { - savedObjectNamespaces = preflightResult!.savedObjectNamespaces; - } - - const migrated = this._migrator.migrateDocument({ - id, - type, - ...(savedObjectNamespace && { namespace: savedObjectNamespace }), - ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), - attributes: { - ...(await this.optionallyEncryptAttributes(type, id, namespace, upsert)), - }, - updated_at: time, - }); - rawUpsert = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); - } - - const doc = { - [type]: await this.optionallyEncryptAttributes(type, id, namespace, attributes), - updated_at: time, - ...(Array.isArray(references) && { references }), - }; - - const body = await this.client - .update({ - id: this._serializer.generateRawId(namespace, type, id), - index: this.getIndexForType(type), - ...getExpectedVersionProperties(version), - refresh, - retry_on_conflict: retryOnConflict, - body: { - doc, - ...(rawUpsert && { upsert: rawUpsert._source }), - }, - _source_includes: ['namespace', 'namespaces', 'originId'], - require_alias: true, - }) - .catch((err) => { - if (SavedObjectsErrorHelpers.isEsUnavailableError(err)) { - throw err; - } - if (SavedObjectsErrorHelpers.isNotFoundError(err)) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - throw err; - }); - - const { originId } = body.get?._source ?? {}; - let namespaces: string[] = []; - if (!this._registry.isNamespaceAgnostic(type)) { - namespaces = body.get?._source.namespaces ?? [ - SavedObjectsUtils.namespaceIdToString(body.get?._source.namespace), - ]; - } - - const result = { - id, - type, - updated_at: time, - version: encodeHitVersion(body), - namespaces, - ...(originId && { originId }), - references, - attributes, - } as SavedObject; - - return this.optionallyDecryptAndRedactSingleResult( - result, - authorizationResult?.typeMap, - attributes + attributes, + options, + }, + this.apiExecutionContext ); } @@ -1927,18 +454,13 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: SavedObjectsCollectMultiNamespaceReferencesObject[], options: SavedObjectsCollectMultiNamespaceReferencesOptions = {} ) { - const namespace = this.getCurrentNamespace(options.namespace); - return collectMultiNamespaceReferences({ - registry: this._registry, - allowedTypes: this._allowedTypes, - client: this.client, - serializer: this._serializer, - getIndexForType: this.getIndexForType.bind(this), - createPointInTimeFinder: this.createPointInTimeFinder.bind(this), - securityExtension: this._securityExtension, - objects, - options: { ...options, namespace }, - }); + return await performCollectMultiNamespaceReferences( + { + objects, + options, + }, + this.apiExecutionContext + ); } /** @@ -1949,22 +471,16 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { spacesToAdd: string[], spacesToRemove: string[], options: SavedObjectsUpdateObjectsSpacesOptions = {} - ) { - const namespace = this.getCurrentNamespace(options.namespace); - return updateObjectsSpaces({ - mappings: this._mappings, - registry: this._registry, - allowedTypes: this._allowedTypes, - client: this.client, - serializer: this._serializer, - logger: this._logger, - getIndexForType: this.getIndexForType.bind(this), - securityExtension: this._securityExtension, - objects, - spacesToAdd, - spacesToRemove, - options: { ...options, namespace }, - }); + ): Promise { + return await performUpdateObjectsSpaces( + { + objects, + spacesToAdd, + spacesToRemove, + options, + }, + this.apiExecutionContext + ); } /** @@ -1974,263 +490,13 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { objects: Array>, options: SavedObjectsBulkUpdateOptions = {} ): Promise> { - const namespace = this.getCurrentNamespace(options.namespace); - const time = getCurrentTime(); - - let bulkGetRequestIndexCounter = 0; - type DocumentToSave = Record; - type ExpectedBulkGetResult = Either< - { type: string; id: string; error: Payload }, - { - type: string; - id: string; - version?: string; - documentToSave: DocumentToSave; - objectNamespace?: string; - esRequestIndex?: number; - } - >; - const expectedBulkGetResults = objects.map((object) => { - const { type, id, attributes, references, version, namespace: objectNamespace } = object; - let error: DecoratedError | undefined; - if (!this._allowedTypes.includes(type)) { - error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } else { - try { - if (objectNamespace === ALL_NAMESPACES_STRING) { - error = SavedObjectsErrorHelpers.createBadRequestError('"namespace" cannot be "*"'); - } - } catch (e) { - error = e; - } - } - - if (error) { - return { - tag: 'Left', - value: { id, type, error: errorContent(error) }, - }; - } - - const documentToSave = { - [type]: attributes, - updated_at: time, - ...(Array.isArray(references) && { references }), - }; - - const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type); - - return { - tag: 'Right', - value: { - type, - id, - version, - documentToSave, - objectNamespace, - ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), - }, - }; - }); - - const validObjects = expectedBulkGetResults.filter(isRight); - if (validObjects.length === 0) { - // We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception. - return { - // Technically the returned array should only contain SavedObject results, but for errors this is not true (we cast to 'any' below) - saved_objects: expectedBulkGetResults.map>( - ({ value }) => value as unknown as SavedObject - ), - }; - } - - // `objectNamespace` is a namespace string, while `namespace` is a namespace ID. - // The object namespace string, if defined, will supersede the operation's namespace ID. - const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); - const getNamespaceId = (objectNamespace?: string) => - objectNamespace !== undefined - ? SavedObjectsUtils.namespaceStringToId(objectNamespace) - : namespace; - const getNamespaceString = (objectNamespace?: string) => objectNamespace ?? namespaceString; - const bulkGetDocs = validObjects - .filter(({ value }) => value.esRequestIndex !== undefined) - .map(({ value: { type, id, objectNamespace } }) => ({ - _id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id), - _index: this.getIndexForType(type), - _source: ['type', 'namespaces'], - })); - const bulkGetResponse = bulkGetDocs.length - ? await this.client.mget({ body: { docs: bulkGetDocs } }, { ignore: [404], meta: true }) - : undefined; - // fail fast if we can't verify a 404 response is from Elasticsearch - if ( - bulkGetResponse && - isNotFoundFromUnsupportedServer({ - statusCode: bulkGetResponse.statusCode, - headers: bulkGetResponse.headers, - }) - ) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); - } - - const authObjects: AuthorizeUpdateObject[] = validObjects.map((element) => { - const { type, id, objectNamespace, esRequestIndex: index } = element.value; - const preflightResult = index !== undefined ? bulkGetResponse?.body.docs[index] : undefined; - return { - type, - id, - objectNamespace, - // @ts-expect-error MultiGetHit._source is optional - existingNamespaces: preflightResult?._source?.namespaces ?? [], - }; - }); - - const authorizationResult = await this._securityExtension?.authorizeBulkUpdate({ - namespace, - objects: authObjects, - }); - - let bulkUpdateRequestIndexCounter = 0; - const bulkUpdateParams: object[] = []; - type ExpectedBulkUpdateResult = Either< - { type: string; id: string; error: Payload }, + return await performBulkUpdate( { - type: string; - id: string; - namespaces: string[]; - documentToSave: DocumentToSave; - esRequestIndex: number; - } - >; - const expectedBulkUpdateResults = await Promise.all( - expectedBulkGetResults.map>( - async (expectedBulkGetResult) => { - if (isLeft(expectedBulkGetResult)) { - return expectedBulkGetResult; - } - - const { esRequestIndex, id, type, version, documentToSave, objectNamespace } = - expectedBulkGetResult.value; - - let namespaces; - let versionProperties; - if (esRequestIndex !== undefined) { - const indexFound = bulkGetResponse?.statusCode !== 404; - const actualResult = indexFound - ? bulkGetResponse?.body.docs[esRequestIndex] - : undefined; - const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; - if ( - !docFound || - // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - !this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace)) - ) { - return { - tag: 'Left', - value: { - id, - type, - error: errorContent( - SavedObjectsErrorHelpers.createGenericNotFoundError(type, id) - ), - }, - }; - } - // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - namespaces = actualResult!._source.namespaces ?? [ - // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), - ]; - versionProperties = getExpectedVersionProperties(version); - } else { - if (this._registry.isSingleNamespace(type)) { - // if `objectNamespace` is undefined, fall back to `options.namespace` - namespaces = [getNamespaceString(objectNamespace)]; - } - versionProperties = getExpectedVersionProperties(version); - } - - const expectedResult = { - type, - id, - namespaces, - esRequestIndex: bulkUpdateRequestIndexCounter++, - documentToSave: expectedBulkGetResult.value.documentToSave, - }; - - bulkUpdateParams.push( - { - update: { - _id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id), - _index: this.getIndexForType(type), - ...versionProperties, - }, - }, - { - doc: { - ...documentToSave, - [type]: await this.optionallyEncryptAttributes( - type, - id, - objectNamespace || namespace, - documentToSave[type] - ), - }, - } - ); - - return { tag: 'Right', value: expectedResult }; - } - ) + objects, + options, + }, + this.apiExecutionContext ); - - const { refresh = DEFAULT_REFRESH_SETTING } = options; - const bulkUpdateResponse = bulkUpdateParams.length - ? await this.client.bulk({ - refresh, - body: bulkUpdateParams, - _source_includes: ['originId'], - require_alias: true, - }) - : undefined; - - const result = { - saved_objects: expectedBulkUpdateResults.map((expectedResult) => { - if (isLeft(expectedResult)) { - return expectedResult.value as any; - } - - const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; - const response = bulkUpdateResponse?.items[esRequestIndex] ?? {}; - const rawResponse = Object.values(response)[0] as any; - - const error = getBulkOperationError(type, id, rawResponse); - if (error) { - return { type, id, error }; - } - - // When a bulk update operation is completed, any fields specified in `_sourceIncludes` will be found in the "get" value of the - // returned object. We need to retrieve the `originId` if it exists so we can return it to the consumer. - const { _seq_no: seqNo, _primary_term: primaryTerm, get } = rawResponse; - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at } = documentToSave; - - const { originId } = get._source; - return { - id, - type, - ...(namespaces && { namespaces }), - ...(originId && { originId }), - updated_at, - version: encodeVersion(seqNo, primaryTerm), - attributes, - references, - }; - }), - }; - - return this.optionallyDecryptAndRedactBulkResult(result, authorizationResult?.typeMap, objects); } /** @@ -2241,241 +507,34 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { id: string, options: SavedObjectsRemoveReferencesToOptions = {} ): Promise { - const namespace = this.getCurrentNamespace(options.namespace); - const { refresh = true } = options; - - await this._securityExtension?.authorizeRemoveReferences({ namespace, object: { type, id } }); - - const allTypes = this._registry.getAllTypes().map((t) => t.name); - - // we need to target all SO indices as all types of objects may have references to the given SO. - const targetIndices = this.getIndicesForTypes(allTypes); - - const { body, statusCode, headers } = await this.client.updateByQuery( + return await performRemoveReferencesTo( { - index: targetIndices, - refresh, - body: { - script: { - source: ` - if (ctx._source.containsKey('references')) { - def items_to_remove = []; - for (item in ctx._source.references) { - if ( (item['type'] == params['type']) && (item['id'] == params['id']) ) { - items_to_remove.add(item); - } - } - ctx._source.references.removeAll(items_to_remove); - } - `, - params: { - type, - id, - }, - lang: 'painless', - }, - conflicts: 'proceed', - ...getSearchDsl(this._mappings, this._registry, { - namespaces: namespace ? [namespace] : undefined, - type: allTypes, - hasReference: { type, id }, - }), - }, - }, - { ignore: [404], meta: true } - ); - // fail fast if we can't verify a 404 is from Elasticsearch - if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); - } - - if (body.failures?.length) { - throw SavedObjectsErrorHelpers.createConflictError( type, id, - `${body.failures.length} references could not be removed` - ); - } - - return { - updated: body.updated!, - }; + options, + }, + this.apiExecutionContext + ); } /** * {@inheritDoc ISavedObjectsRepository.incrementCounter} */ async incrementCounter( - type: string, - id: string, - counterFields: Array, - options?: SavedObjectsIncrementCounterOptions - ) { - // This is not exposed on the SOC, there are no authorization or audit logging checks - if (typeof type !== 'string') { - throw new Error('"type" argument must be a string'); - } - - const isArrayOfCounterFields = - Array.isArray(counterFields) && - counterFields.every( - (field) => - typeof field === 'string' || (isObject(field) && typeof field.fieldName === 'string') - ); - - if (!isArrayOfCounterFields) { - throw new Error( - '"counterFields" argument must be of type Array' - ); - } - if (!this._allowedTypes.includes(type)) { - throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); - } - - return this.incrementCounterInternal(type, id, counterFields, options); - } - - /** @internal incrementCounter function that is used internally and bypasses validation checks. */ - private async incrementCounterInternal( type: string, id: string, counterFields: Array, options: SavedObjectsIncrementCounterOptions = {} - ): Promise> { - const { - migrationVersion, - typeMigrationVersion, - refresh = DEFAULT_REFRESH_SETTING, - initialize = false, - upsertAttributes, - managed, - } = options; - - if (!id) { - throw SavedObjectsErrorHelpers.createBadRequestError('id cannot be empty'); // prevent potentially upserting a saved object with an empty ID - } - - const normalizedCounterFields = counterFields.map((counterField) => { - /** - * no counterField configs provided, instead a field name string was passed. - * ie `incrementCounter(so_type, id, ['my_field_name'])` - * Using the default of incrementing by 1 - */ - if (typeof counterField === 'string') { - return { - fieldName: counterField, - incrementBy: initialize ? 0 : 1, - }; - } - - const { incrementBy = 1, fieldName } = counterField; - - return { - fieldName, - incrementBy: initialize ? 0 : incrementBy, - }; - }); - const namespace = normalizeNamespace(options.namespace); - - const time = getCurrentTime(); - let savedObjectNamespace; - let savedObjectNamespaces: string[] | undefined; - - if (this._registry.isSingleNamespace(type) && namespace) { - savedObjectNamespace = namespace; - } else if (this._registry.isMultiNamespace(type)) { - // note: this check throws an error if the object is found but does not exist in this namespace - const preflightResult = await this.preflightCheckNamespaces({ + ) { + return await performIncrementCounter( + { type, id, - namespace, - }); - if (preflightResult.checkResult === 'found_outside_namespace') { - throw SavedObjectsErrorHelpers.createConflictError(type, id); - } - - if (preflightResult.checkResult === 'not_found') { - // If an upsert would result in the creation of a new object, we need to check for alias conflicts too. - // This takes an extra round trip to Elasticsearch, but this won't happen often. - // TODO: improve performance by combining these into a single preflight check - await this.preflightCheckForUpsertAliasConflict(type, id, namespace); - } - - savedObjectNamespaces = preflightResult.savedObjectNamespaces; - } - - // attributes: { [counterFieldName]: incrementBy }, - const migrated = this._migrator.migrateDocument({ - id, - type, - ...(savedObjectNamespace && { namespace: savedObjectNamespace }), - ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), - attributes: { - ...(upsertAttributes ?? {}), - ...normalizedCounterFields.reduce((acc, counterField) => { - const { fieldName, incrementBy } = counterField; - acc[fieldName] = incrementBy; - return acc; - }, {} as Record), + counterFields, + options, }, - migrationVersion, - typeMigrationVersion, - managed, - updated_at: time, - }); - - const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); - - const body = await this.client.update({ - id: raw._id, - index: this.getIndexForType(type), - refresh, - require_alias: true, - _source: true, - body: { - script: { - source: ` - for (int i = 0; i < params.counterFieldNames.length; i++) { - def counterFieldName = params.counterFieldNames[i]; - def count = params.counts[i]; - - if (ctx._source[params.type][counterFieldName] == null) { - ctx._source[params.type][counterFieldName] = count; - } - else { - ctx._source[params.type][counterFieldName] += count; - } - } - ctx._source.updated_at = params.time; - `, - lang: 'painless', - params: { - counts: normalizedCounterFields.map( - (normalizedCounterField) => normalizedCounterField.incrementBy - ), - counterFieldNames: normalizedCounterFields.map( - (normalizedCounterField) => normalizedCounterField.fieldName - ), - time, - type, - }, - }, - upsert: raw._source, - }, - }); - - const { originId } = body.get?._source ?? {}; - return { - id, - type, - ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), - ...(originId && { originId }), - updated_at: time, - references: body.get?._source.references ?? [], - version: encodeHitVersion(body), - attributes: body.get?._source[type], - ...(managed && { managed }), - }; + this.apiExecutionContext + ); } /** @@ -2486,69 +545,14 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { options: SavedObjectsOpenPointInTimeOptions = {}, internalOptions: SavedObjectsFindInternalOptions = {} ): Promise { - const { disableExtensions } = internalOptions; - let namespaces!: string[]; - if (disableExtensions || !this._spacesExtension) { - namespaces = options.namespaces ?? [DEFAULT_NAMESPACE_STRING]; - // If the consumer specified `namespaces: []`, throw a Bad Request error - if (namespaces.length === 0) - throw SavedObjectsErrorHelpers.createBadRequestError( - 'options.namespaces cannot be an empty array' - ); - } - - const { keepAlive = '5m', preference } = options; - const types = Array.isArray(type) ? type : [type]; - const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); - if (allowedTypes.length === 0) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(); - } - - if (!disableExtensions && this._spacesExtension) { - try { - namespaces = await this._spacesExtension.getSearchableNamespaces(options.namespaces); - } catch (err) { - if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { - // The user is not authorized to access any space, throw a bad request error. - throw SavedObjectsErrorHelpers.createBadRequestError(); - } - throw err; - } - if (namespaces.length === 0) { - // The user is authorized to access *at least one space*, but not any of the spaces they requested; throw a bad request error. - throw SavedObjectsErrorHelpers.createBadRequestError(); - } - } - - if (!disableExtensions && this._securityExtension) { - await this._securityExtension.authorizeOpenPointInTime({ - namespaces: new Set(namespaces), - types: new Set(types), - }); - } - - const esOptions = { - index: this.getIndicesForTypes(allowedTypes), - keep_alive: keepAlive, - ...(preference ? { preference } : {}), - }; - - const { body, statusCode, headers } = await this.client.openPointInTime(esOptions, { - ignore: [404], - meta: true, - }); - - if (statusCode === 404) { - if (!isSupportedEsServer(headers)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); - } else { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(); - } - } - - return { - id: body.id, - }; + return await performOpenPointInTime( + { + type, + options, + internalOptions, + }, + this.apiExecutionContext + ); } /** @@ -2560,9 +564,8 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { internalOptions: SavedObjectsFindInternalOptions = {} ): Promise { const { disableExtensions } = internalOptions; - - if (!disableExtensions && this._securityExtension) { - this._securityExtension.auditClosePointInTime(); + if (!disableExtensions && this.extensions.securityExtension) { + this.extensions.securityExtension.auditClosePointInTime(); } return await this.client.closePointInTime({ @@ -2579,7 +582,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { internalOptions?: SavedObjectsFindInternalOptions ): ISavedObjectsPointInTimeFinder { return new PointInTimeFinder(findOptions, { - logger: this._logger, + logger: this.logger, client: this, ...dependencies, internalOptions, @@ -2590,334 +593,6 @@ export class SavedObjectsRepository implements ISavedObjectsRepository { * {@inheritDoc ISavedObjectsRepository.getCurrentNamespace} */ getCurrentNamespace(namespace?: string) { - if (this._spacesExtension) { - return this._spacesExtension.getCurrentNamespace(namespace); - } - return normalizeNamespace(namespace); - } - - /** - * Returns index specified by the given type or the default index - * - * @param type - the type - */ - private getIndexForType(type: string) { - return getIndexForType({ - type, - defaultIndex: this._index, - typeRegistry: this._registry, - kibanaVersion: this._migrator.kibanaVersion, - }); - } - - /** - * Returns an array of indices as specified in `this._registry` for each of the - * given `types`. If any of the types don't have an associated index, the - * default index `this._index` will be included. - * - * @param types The types whose indices should be retrieved - */ - private getIndicesForTypes(types: string[]) { - return unique(types.map((t) => this.getIndexForType(t))); - } - - private _rawToSavedObject( - raw: SavedObjectsRawDoc, - options?: SavedObjectsRawDocParseOptions - ): SavedObject { - const savedObject = this._serializer.rawToSavedObject(raw, options); - const { namespace, type } = savedObject; - if (this._registry.isSingleNamespace(type)) { - savedObject.namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)]; - } - - return omit(savedObject, ['namespace']) as SavedObject; - } - - private rawDocExistsInNamespaces(raw: SavedObjectsRawDoc, namespaces: string[]) { - return rawDocExistsInNamespaces(this._registry, raw, namespaces); - } - - private rawDocExistsInNamespace(raw: SavedObjectsRawDoc, namespace: string | undefined) { - return rawDocExistsInNamespace(this._registry, raw, namespace); - } - - /** - * Pre-flight check to ensure that a multi-namespace object exists in the current namespace. - */ - private async preflightCheckNamespaces({ - type, - id, - namespace, - initialNamespaces, - }: PreflightCheckNamespacesParams): Promise { - if (!this._registry.isMultiNamespace(type)) { - throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`); - } - - const { body, statusCode, headers } = await this.client.get( - { - id: this._serializer.generateRawId(undefined, type, id), - index: this.getIndexForType(type), - }, - { - ignore: [404], - meta: true, - } - ); - - const namespaces = initialNamespaces ?? [SavedObjectsUtils.namespaceIdToString(namespace)]; - - const indexFound = statusCode !== 404; - if (indexFound && isFoundGetResponse(body)) { - if (!this.rawDocExistsInNamespaces(body, namespaces)) { - return { checkResult: 'found_outside_namespace' }; - } - return { - checkResult: 'found_in_namespace', - savedObjectNamespaces: initialNamespaces ?? getSavedObjectNamespaces(namespace, body), - rawDocSource: body, - }; - } else if (isNotFoundFromUnsupportedServer({ statusCode, headers })) { - // checking if the 404 is from Elasticsearch - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return { - checkResult: 'not_found', - savedObjectNamespaces: initialNamespaces ?? getSavedObjectNamespaces(namespace), - }; - } - - /** - * Pre-flight check to ensure that an upsert which would create a new object does not result in an alias conflict. - */ - private async preflightCheckForUpsertAliasConflict( - type: string, - id: string, - namespace: string | undefined - ) { - const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); - const [{ error }] = await preflightCheckForCreate({ - registry: this._registry, - client: this.client, - serializer: this._serializer, - getIndexForType: this.getIndexForType.bind(this), - createPointInTimeFinder: this.createPointInTimeFinder.bind(this), - objects: [{ type, id, namespaces: [namespaceString] }], - }); - if (error?.type === 'aliasConflict') { - throw SavedObjectsErrorHelpers.createConflictError(type, id); - } - // any other error from this check does not matter - } - - /** The `initialNamespaces` field (create, bulkCreate) is used to create an object in an initial set of spaces. */ - private validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) { - if (!initialNamespaces) { - return; - } - - if (this._registry.isNamespaceAgnostic(type)) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" cannot be used on space-agnostic types' - ); - } else if (!initialNamespaces.length) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" must be a non-empty array of strings' - ); - } else if ( - !this._registry.isShareable(type) && - (initialNamespaces.length > 1 || initialNamespaces.includes(ALL_NAMESPACES_STRING)) - ) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" can only specify a single space when used with space-isolated types' - ); - } - } - - /** The object-specific `namespaces` field (bulkGet) is used to check if an object exists in any of a given number of spaces. */ - private validateObjectNamespaces(type: string, id: string, namespaces: string[] | undefined) { - if (!namespaces) { - return; - } - - if (this._registry.isNamespaceAgnostic(type)) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"namespaces" cannot be used on space-agnostic types' - ); - } else if (!namespaces.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } else if ( - !this._registry.isShareable(type) && - (namespaces.length > 1 || namespaces.includes(ALL_NAMESPACES_STRING)) - ) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"namespaces" can only specify a single space when used with space-isolated types' - ); - } - } - - /** Validate a migrated doc against the registered saved object type's schema. */ - private validateObjectForCreate(type: string, doc: SavedObjectSanitizedDoc) { - if (!this._registry.getType(type)) { - return; - } - const validator = this.getTypeValidator(type); - try { - validator.validate(doc, this._migrator.kibanaVersion); - } catch (error) { - throw SavedObjectsErrorHelpers.createBadRequestError(error.message); - } - } - - private getTypeValidator(type: string): SavedObjectsTypeValidator { - if (!this.typeValidatorMap[type]) { - const savedObjectType = this._registry.getType(type); - this.typeValidatorMap[type] = new SavedObjectsTypeValidator({ - logger: this._logger.get('type-validator'), - type, - validationMap: savedObjectType!.schemas ?? {}, - defaultVersion: this._migrator.kibanaVersion, - }); - } - return this.typeValidatorMap[type]!; - } - - /** This is used when objects are created. */ - private validateOriginId(type: string, objectOrOptions: { originId?: string }) { - if ( - Object.keys(objectOrOptions).includes('originId') && - !this._registry.isMultiNamespace(type) - ) { - throw SavedObjectsErrorHelpers.createBadRequestError( - '"originId" can only be set for multi-namespace object types' - ); - } - } - - /** - * Saved objects with encrypted attributes should have IDs that are hard to guess, especially since IDs are part of the AAD used during - * encryption, that's why we control them within this function and don't allow consumers to specify their own IDs directly for encryptable - * types unless overwriting the original document. - */ - private getValidId( - type: string, - id: string | undefined, - version: string | undefined, - overwrite: boolean | undefined - ) { - if (!this._encryptionExtension?.isEncryptableType(type)) { - return id || SavedObjectsUtils.generateId(); - } - if (!id) { - return SavedObjectsUtils.generateId(); - } - // only allow a specified ID if we're overwriting an existing ESO with a Version - // this helps us ensure that the document really was previously created using ESO - // and not being used to get around the specified ID limitation - const canSpecifyID = (overwrite && version) || SavedObjectsUtils.isRandomId(id); - if (!canSpecifyID) { - throw SavedObjectsErrorHelpers.createBadRequestError( - 'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.' - ); - } - return id; - } - - private async optionallyEncryptAttributes( - type: string, - id: string, - namespaceOrNamespaces: string | string[] | undefined, - attributes: T - ): Promise { - if (!this._encryptionExtension?.isEncryptableType(type)) { - return attributes; - } - const namespace = Array.isArray(namespaceOrNamespaces) - ? namespaceOrNamespaces[0] - : namespaceOrNamespaces; - const descriptor = { type, id, namespace }; - return this._encryptionExtension.encryptAttributes( - descriptor, - attributes as Record - ) as unknown as T; - } - - private async optionallyDecryptAndRedactSingleResult( - object: SavedObject, - typeMap: AuthorizationTypeMap | undefined, - originalAttributes?: T - ) { - if (this._encryptionExtension?.isEncryptableType(object.type)) { - object = await this._encryptionExtension.decryptOrStripResponseAttributes( - object, - originalAttributes - ); - } - if (typeMap) { - return this._securityExtension!.redactNamespaces({ typeMap, savedObject: object }); - } - return object; - } - - private async optionallyDecryptAndRedactBulkResult< - T, - R extends { saved_objects: Array> }, - A extends string, - O extends Array<{ attributes: T }> - >(response: R, typeMap: AuthorizationTypeMap | undefined, originalObjects?: O) { - const modifiedObjects = await Promise.all( - response.saved_objects.map(async (object, index) => { - if (object.error) { - // If the bulk operation failed, the object will not have an attributes field at all, it will have an error field instead. - // In this case, don't attempt to decrypt, just return the object. - return object; - } - const originalAttributes = originalObjects?.[index].attributes; - return await this.optionallyDecryptAndRedactSingleResult( - object, - typeMap, - originalAttributes - ); - }) - ); - return { ...response, saved_objects: modifiedObjects }; + return this.helpers.common.getCurrentNamespace(namespace); } } - -/** - * Returns a string array of namespaces for a given saved object. If the saved object is undefined, the result is an array that contains the - * current namespace. Value may be undefined if an existing saved object has no namespaces attribute; this should not happen in normal - * operations, but it is possible if the Elasticsearch document is manually modified. - * - * @param namespace The current namespace. - * @param document Optional existing saved object that was obtained in a preflight operation. - */ -function getSavedObjectNamespaces( - namespace?: string, - document?: SavedObjectsRawDoc -): string[] | undefined { - if (document) { - return document._source?.namespaces; - } - return [SavedObjectsUtils.namespaceIdToString(namespace)]; -} - -/** - * Extracts the contents of a decorated error to return the attributes for bulk operations. - */ -const errorContent = (error: DecoratedError) => error.output.payload; - -const unique = (array: string[]) => [...new Set(array)]; - -/** - * Type and type guard function for converting a possibly not existent doc to an existent doc. - */ -type GetResponseFound = estypes.GetResponse & - Required< - Pick, '_primary_term' | '_seq_no' | '_version' | '_source'> - >; - -const isFoundGetResponse = ( - doc: estypes.GetResponse -): doc is GetResponseFound => doc.found; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_bulk_delete_internal_types.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_bulk_delete_internal_types.ts index 6319662e8bb98f..91df89b9be0702 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_bulk_delete_internal_types.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository_bulk_delete_internal_types.ts @@ -12,7 +12,7 @@ import type { ErrorCause, } from '@elastic/elasticsearch/lib/api/types'; import type { estypes, TransportResult } from '@elastic/elasticsearch'; -import type { Either } from './internal_utils'; +import type { Either } from './apis/utils'; import type { DeleteLegacyUrlAliasesParams } from './legacy_url_aliases'; /** diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/api_context.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/api_context.mock.ts new file mode 100644 index 00000000000000..3b4da315868fa3 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/api_context.mock.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { + elasticsearchClientMock, + ElasticsearchClientMock, +} from '@kbn/core-elasticsearch-client-server-mocks'; +import { SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal'; +import { serializerMock } from '@kbn/core-saved-objects-base-server-mocks'; +import type { ApiExecutionContext } from '../lib/apis/types'; +import { apiHelperMocks, RepositoryHelpersMock } from './api_helpers.mocks'; +import { savedObjectsExtensionsMock } from './saved_objects_extensions.mock'; +import { createMigratorMock, KibanaMigratorMock } from './migrator.mock'; + +export type ApiExecutionContextMock = Pick & { + registry: SavedObjectTypeRegistry; + helpers: RepositoryHelpersMock; + extensions: ReturnType; + client: ElasticsearchClientMock; + serializer: ReturnType; + migrator: KibanaMigratorMock; + logger: MockedLogger; +}; + +const createApiExecutionContextMock = (): ApiExecutionContextMock => { + return { + registry: new SavedObjectTypeRegistry(), + helpers: apiHelperMocks.create(), + extensions: savedObjectsExtensionsMock.create(), + client: elasticsearchClientMock.createElasticsearchClient(), + serializer: serializerMock.create(), + migrator: createMigratorMock(), + logger: loggerMock.create(), + allowedTypes: ['foo', 'bar'], + mappings: { properties: { mockMappings: { type: 'text' } } }, + }; +}; + +export const apiContextMock = { + create: createApiExecutionContextMock, +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/api_helpers.mocks.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/api_helpers.mocks.ts new file mode 100644 index 00000000000000..d5caf01cf59c05 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/api_helpers.mocks.ts @@ -0,0 +1,110 @@ +/* + * 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 { PublicMethodsOf } from '@kbn/utility-types'; +import type { + CommonHelper, + EncryptionHelper, + ValidationHelper, + PreflightCheckHelper, + SerializerHelper, +} from '../lib/apis/helpers'; + +export type CommonHelperMock = jest.Mocked>; + +const createCommonHelperMock = (): CommonHelperMock => { + const mock: CommonHelperMock = { + createPointInTimeFinder: jest.fn(), + getIndexForType: jest.fn(), + getIndicesForTypes: jest.fn(), + getCurrentNamespace: jest.fn(), + getValidId: jest.fn(), + }; + + mock.getIndexForType.mockReturnValue('.kibana_mock'); + mock.getIndicesForTypes.mockReturnValue(['.kibana_mock']); + mock.getCurrentNamespace.mockImplementation((space) => space ?? 'default'); + mock.getValidId.mockReturnValue('valid-id'); + + return mock; +}; + +export type EncryptionHelperMock = jest.Mocked>; + +const createEncryptionHelperMock = (): EncryptionHelperMock => { + const mock: EncryptionHelperMock = { + optionallyEncryptAttributes: jest.fn(), + optionallyDecryptAndRedactSingleResult: jest.fn(), + optionallyDecryptAndRedactBulkResult: jest.fn(), + }; + + return mock; +}; + +export type ValidationHelperMock = jest.Mocked>; + +const createValidationHelperMock = (): ValidationHelperMock => { + const mock: ValidationHelperMock = { + validateInitialNamespaces: jest.fn(), + validateObjectNamespaces: jest.fn(), + validateObjectForCreate: jest.fn(), + validateOriginId: jest.fn(), + }; + + return mock; +}; + +export type SerializerHelperMock = jest.Mocked>; + +const createSerializerHelperMock = (): SerializerHelperMock => { + const mock: SerializerHelperMock = { + rawToSavedObject: jest.fn(), + }; + + return mock; +}; + +export type PreflightCheckHelperMock = jest.Mocked>; + +const createPreflightCheckHelperMock = (): PreflightCheckHelperMock => { + const mock: PreflightCheckHelperMock = { + preflightCheckForCreate: jest.fn(), + preflightCheckForBulkDelete: jest.fn(), + preflightCheckNamespaces: jest.fn(), + preflightCheckForUpsertAliasConflict: jest.fn(), + }; + + return mock; +}; + +export interface RepositoryHelpersMock { + common: CommonHelperMock; + encryption: EncryptionHelperMock; + validation: ValidationHelperMock; + preflight: PreflightCheckHelperMock; + serializer: SerializerHelperMock; +} + +const createRepositoryHelpersMock = (): RepositoryHelpersMock => { + return { + common: createCommonHelperMock(), + encryption: createEncryptionHelperMock(), + validation: createValidationHelperMock(), + preflight: createPreflightCheckHelperMock(), + serializer: createSerializerHelperMock(), + }; +}; + +export const apiHelperMocks = { + create: createRepositoryHelpersMock, + createCommonHelper: createCommonHelperMock, + createEncryptionHelper: createEncryptionHelperMock, + createValidationHelper: createValidationHelperMock, + createSerializerHelper: createSerializerHelperMock, + createPreflightCheckHelper: createPreflightCheckHelperMock, +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/index.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/index.ts index a2d933b58404e0..22569e9437895d 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/index.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/index.ts @@ -9,3 +9,13 @@ export { savedObjectsPointInTimeFinderMock } from './point_in_time_finder.mock'; export { kibanaMigratorMock } from './kibana_migrator.mock'; export { repositoryMock } from './repository.mock'; +export { + apiHelperMocks, + type SerializerHelperMock, + type CommonHelperMock, + type ValidationHelperMock, + type EncryptionHelperMock, + type RepositoryHelpersMock, + type PreflightCheckHelperMock, +} from './api_helpers.mocks'; +export { apiContextMock, type ApiExecutionContextMock } from './api_context.mock'; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/migrator.mock.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/migrator.mock.ts new file mode 100644 index 00000000000000..94f221e7cfbc83 --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/mocks/migrator.mock.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { IKibanaMigrator } from '@kbn/core-saved-objects-base-server-internal'; + +export type KibanaMigratorMock = jest.Mocked; + +export const createMigratorMock = (kibanaVersion: string = '8.0.0'): KibanaMigratorMock => { + return { + kibanaVersion, + runMigrations: jest.fn(), + prepareMigrations: jest.fn(), + getStatus$: jest.fn(), + getActiveMappings: jest.fn(), + migrateDocument: jest.fn(), + }; +}; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts index abeab2a8f2e7c8..78d81a8b86be12 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts @@ -333,7 +333,9 @@ export const createType = ( hidden: false, namespaceType: 'single', mappings: { - properties: mappings.properties[type].properties! as SavedObjectsMappingProperties, + properties: (mappings.properties[type] + ? mappings.properties[type].properties! + : {}) as SavedObjectsMappingProperties, }, migrations: { '1.1.1': (doc) => doc }, ...parts, diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/tsconfig.json b/packages/core/saved-objects/core-saved-objects-api-server-internal/tsconfig.json index 6f7ca16e5d58a3..dbb7f83fa94b27 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/tsconfig.json +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/tsconfig.json @@ -32,6 +32,7 @@ "@kbn/core-http-server", "@kbn/core-http-server-mocks", "@kbn/core-saved-objects-migration-server-internal", + "@kbn/utility-types", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts index 0bcdb817940c82..fe62b40285ca48 100644 --- a/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts +++ b/x-pack/plugins/security/server/saved_objects/saved_objects_security_extension.ts @@ -11,7 +11,7 @@ import type { SavedObjectsResolveResponse, } from '@kbn/core-saved-objects-api-server'; import type { SavedObjectsClient } from '@kbn/core-saved-objects-api-server-internal'; -import { isBulkResolveError } from '@kbn/core-saved-objects-api-server-internal/src/lib/internal_bulk_resolve'; +import { isBulkResolveError } from '@kbn/core-saved-objects-api-server-internal/src/lib/apis/internals/internal_bulk_resolve'; import { LEGACY_URL_ALIAS_TYPE } from '@kbn/core-saved-objects-base-server-internal'; import type { LegacyUrlAliasTarget } from '@kbn/core-saved-objects-common'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server';