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 69dca03cec051..b25fd29f5441c 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 fd96c54450cf7..0000000000000 --- 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 0000000000000..c7802397739c2 --- /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 0000000000000..768dcf91b61c9 --- /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 0000000000000..19ac91083c8ce --- /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 0000000000000..74ab7026eb177 --- /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 0000000000000..b9c0f10a9021f --- /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 0000000000000..6f110c3d6f908 --- /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 0000000000000..a107e74dcb539 --- /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 0000000000000..b8b9b8a0d2dbb --- /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 0000000000000..f687091ca8ab4 --- /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 0000000000000..2848f8d600658 --- /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 0000000000000..ec24818df90a0 --- /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 0000000000000..d2fbe46fc6c85 --- /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 0000000000000..51b8723cd6d05 --- /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 0000000000000..e70f08b225c5e --- /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 0000000000000..7c59a738ad3d7 --- /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 0000000000000..d51727ec3a859 --- /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 0000000000000..e5d609f590833 --- /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 0000000000000..96224953ba459 --- /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 0000000000000..0a22b108d53b5 --- /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 0000000000000..3271f1ab25f21 --- /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 5476f99c3b37d..4debe9b5b3995 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 b4e788dd2973a..5450f0c739ce3 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 d8b25dc886f20..260fa7c6adf16 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 0000000000000..6ff63bc437189 --- /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 0000000000000..a7e0b895bdc15 --- /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 277d1ae4af34a..51a88b5951288 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 232c19fa7a840..4b619764a042c 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 907474a008a3f..6e143004dfb3a 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 fe8076b51e5dd..e48f4ed1f6907 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 f2901e4b53187..c07134259d4b8 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 ae09b0e1e4228..aec1ac9991d4c 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 043975d5bb52b..20fa8daaac167 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 7432b7ae3e6ae..f96c3dfddc8d0 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 dd6bdc5c3e17d..62b006cc0d9ac 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 0000000000000..e5cf78c4185d8 --- /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 0000000000000..78c3e8d1faf92 --- /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 0000000000000..5b3117a0cd52f --- /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 0000000000000..884afc573262d --- /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 0000000000000..bfa07e4bf5dcc --- /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 0000000000000..eceb738ac7ae9 --- /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 0000000000000..d90d4fc9403eb --- /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 0000000000000..9403945a9a650 --- /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 0000000000000..01f7dc3cd79a7 --- /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 44c81bc6eb49f..c9f90073da24f 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 a489e4afa91c3..8ed1f0a3c965a 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 0000000000000..f3562dffb1e86 --- /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 1ffc5fcde62d8..f06894bcca1e5 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 0000000000000..3b429a2a7dfa6 --- /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 300dc1349b59d..0000000000000 --- 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 66751101dd8b7..0000000000000 --- 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 a9c1871e2488e..6a2cfa16e2470 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 3a0ff953dbff5..409138cd635de 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 6319662e8bb98..91df89b9be070 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 0000000000000..3b4da315868fa --- /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 0000000000000..d5caf01cf59c0 --- /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 a2d933b58404e..22569e9437895 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 0000000000000..94f221e7cfbc8 --- /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 abeab2a8f2e7c..78d81a8b86be1 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 6f7ca16e5d58a..dbb7f83fa94b2 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 0bcdb817940c8..fe62b40285ca4 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';