SavedObjectsRepository code cleanup (#157154)

## Summary

Structural cleanup of the `SavedObjectsRepository` code, by extracting
the actual implementation of each API to their individual file (as it
was initiated for some by Joe a while ago, e.g `updateObjectsSpaces`)

### Why doing that, and why now?

I remember discussing about this extraction with Joe for the first time
like, what, almost 3 years ago? The 2.5k line SOR is a beast, and the
only reason we did not refactor that yet is because of (the lack of)
priorization of tech debt (and lack of courage, probably).

So, why now? Well, with the changes we're planning to perform to the SOR
code for serverless, I thought that doing a bit of cleanup beforehand
was probably a wise thing. So I took this on-week time to tackle this (I
know, so much for an on-week project, right?)

### API extraction

All of these APIs in the SOR class now look like:

```ts
  /**
   * {@inheritDoc ISavedObjectsRepository.create}
   */
  public async create<T = unknown>(
    type: string,
    attributes: T,
    options: SavedObjectsCreateOptions = {}
  ): Promise<SavedObject<T>> {
    return await performCreate(
      {
        type,
        attributes,
        options,
      },
      this.apiExecutionContext
    );
  }
```

This separation allows a better isolation, testability, readability and
therefore maintainability overall.

### Structure

```
@kbn/core-saved-objects-api-server-internal
  - /src/lib
    - repository.ts
    - /apis
      - create.ts
      - delete.ts
      - ....
      - /helpers
      - /utils
      - /internals
```    


There was a *massive* amount of helpers, utilities and such, both as
internal functions on the SOR, and as external utilities. Some being
stateless, some requiring access to parts of the SOR to perform calls...

I introduced 3 concepts here, as you can see on the structure:

#### utils

Base utility functions, receiving (mostly) parameters from a given API
call's option (e.g the type or id of a document, but not the type
registry).

#### helpers

'Stateful' helpers. These helpers were mostly here to receive the
utility functions that were extracted from the SOR. They are
instantiated with the SOR's context (e.g type registry, mappings and so
on), to avoid the caller to such helpers to have to pass all the
parameters again.

#### internals

I would call them 'utilities with business logic'. These are the 'big'
chunks of logic called by the APIs. E.g `preflightCheckForCreate`,
`internalBulkResolve` and so on.

Note that given the legacy of the code, the frontier between those
concept is quite thin sometimes, but I wanted to regroups things a bit,
and also I aimed at increasing the developer experience by avoiding to
call methods with 99 parameters (which is why the helpers were created).

### Tests

Test coverage was not altered by this PR. The base repository tests
(`packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts`)
and the extension tests
(`packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.{ext}_extension.test.ts`)
were remain untouched. These tests are performing 'almost unmocked'
tests, somewhat close to integration tests, so it would probably be
worth keeping them.

The new structure allow more low-level, unitary testing of the
individual APIs. I did **NOT** add proper unit test coverage for the
extracted APIs, as the amount of work it represent is way more
significant than the refactor itself (and, once again, the existing
coverage still applies / function here).

The testing utilities and mocks were added in the PR though, and an
example of what the per-API unit test could look like was also added
(`packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/remove_references_to.test.ts`).

Overall, I think it of course would be beneficial to add the missing
unit test coverage, but given the amount of work it represent, and the
fact that the code is already tested by the repository test and the
(quite exhaustive) FTR test suites, I don't think it's worth the effort
right now given our other priorities.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pierre Gayvallet 2023-05-11 03:25:27 -04:00 committed by GitHub
parent b70496ee82
commit 3b6b7ad9b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 4290 additions and 2800 deletions

View file

@ -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.

View file

@ -1,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`1, 1 throws Error 1`] = `"Already have entry with this priority"`;

View file

@ -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<T = unknown> {
objects: Array<SavedObjectsBulkCreateObject<T>>;
options: SavedObjectsCreateOptions;
}
type ExpectedResult = Either<
{ type: string; id?: string; error: Payload },
{
method: 'index' | 'create';
object: SavedObjectsBulkCreateObject & { id: string };
preflightCheckIndex?: number;
}
>;
export const performBulkCreate = async <T>(
{ objects, options }: PerformBulkCreateParams<T>,
{
registry,
helpers,
allowedTypes,
client,
serializer,
migrator,
extensions = {},
}: ApiExecutionContext
): Promise<SavedObjectsBulkResponse<T>> => {
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<ExpectedResult>((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<SavedObject<T>>(
({ value }) => value as unknown as SavedObject<T>
),
};
}
const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace);
const preflightCheckObjects = validObjects
.filter(({ value }) => value.preflightCheckIndex !== undefined)
.map<PreflightCheckForCreateObject>(({ 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<Promise<ExpectedBulkResult>>(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<T>;
/**
* 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
);
};

View file

@ -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<T = unknown> {
objects: SavedObjectsBulkDeleteObject[];
options: SavedObjectsBulkDeleteOptions;
}
export const performBulkDelete = async <T>(
{ objects, options }: PerformBulkDeleteParams<T>,
{
registry,
helpers,
allowedTypes,
client,
serializer,
extensions = {},
logger,
mappings,
}: ApiExecutionContext
): Promise<SavedObjectsBulkDeleteResponse> => {
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<BulkDeleteExpectedBulkGetResult>((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<ExpectedBulkDeleteResult>((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;
}

View file

@ -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<T = unknown> {
objects: SavedObjectsBulkGetObject[];
options: SavedObjectsGetOptions;
}
export const performBulkGet = async <T>(
{ objects, options }: PerformBulkGetParams<T>,
{ helpers, allowedTypes, client, serializer, registry, extensions = {} }: ApiExecutionContext
): Promise<SavedObjectsBulkResponse<T>> => {
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<string[]> | 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<Promise<ExpectedBulkGetResult>>(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<SavedObject<T>>(
({ value }) => value as unknown as SavedObject<T>
),
};
}
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<SavedObjectsRawDocSource>(
{
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<T>;
}
// @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
);
};

View file

@ -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<T = unknown> {
objects: SavedObjectsBulkResolveObject[];
options: SavedObjectsResolveOptions;
}
export const performBulkResolve = async <T>(
{ objects, options }: PerformCreateParams<T>,
apiExecutionContext: ApiExecutionContext
): Promise<SavedObjectsBulkResolveResponse<T>> => {
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<T>({
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<SavedObjectsResolveResponse<T>>((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<T>,
outcome: 'exactMatch',
};
}
return result;
});
return { resolved_objects: resolvedObjects };
};

View file

@ -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<T = unknown> {
objects: Array<SavedObjectsBulkUpdateObject<T>>;
options: SavedObjectsBulkUpdateOptions;
}
export const performBulkUpdate = async <T>(
{ objects, options }: PerformUpdateParams<T>,
{ registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext
): Promise<SavedObjectsBulkUpdateResponse<T>> => {
const { common: commonHelper, encryption: encryptionHelper } = helpers;
const { securityExtension } = extensions;
const namespace = commonHelper.getCurrentNamespace(options.namespace);
const time = getCurrentTime();
let bulkGetRequestIndexCounter = 0;
type DocumentToSave = Record<string, unknown>;
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<ExpectedBulkGetResult>((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<SavedObject<T>>(
({ value }) => value as unknown as SavedObject<T>
),
};
}
// `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<Promise<ExpectedBulkUpdateResult>>(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
);
};

View file

@ -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<T = unknown> {
objects: SavedObjectsCheckConflictsObject[];
options: SavedObjectsBaseOptions;
}
export const performCheckConflicts = async <T>(
{ objects, options }: PerformCheckConflictsParams<T>,
{ registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext
): Promise<SavedObjectsCheckConflictsResponse> => {
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<ExpectedBulkGetResult>((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<SavedObjectsRawDocSource>(
{
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 };
};

View file

@ -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<T = unknown> {
objects: SavedObjectsCollectMultiNamespaceReferencesObject[];
options: SavedObjectsCollectMultiNamespaceReferencesOptions;
}
export const performCollectMultiNamespaceReferences = async <T>(
{ objects, options }: PerformCreateParams<T>,
{ registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext
): Promise<SavedObjectsCollectMultiNamespaceReferencesResponse> => {
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 },
});
};

View file

@ -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<T = unknown> {
type: string;
attributes: T;
options: SavedObjectsCreateOptions;
}
export const performCreate = async <T>(
{ type, attributes, options }: PerformCreateParams<T>,
{
registry,
helpers,
allowedTypes,
client,
serializer,
migrator,
extensions = {},
}: ApiExecutionContext
): Promise<SavedObject<T>> => {
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<T>);
const raw = serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc<T>);
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<T>({ ...raw, ...body }, { migrationVersionCompatibility }),
authorizationResult?.typeMap,
attributes
);
};

View file

@ -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<T = unknown> {
type: string;
id: string;
options: SavedObjectsDeleteOptions;
}
export const performDelete = async <T>(
{ type, id, options }: PerformDeleteParams<T>,
{
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 },
})}`
);
};

View file

@ -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<T = unknown> {
namespace: string;
options: SavedObjectsDeleteByNamespaceOptions;
}
export const performDeleteByNamespace = async <T>(
{ namespace, options }: PerformDeleteByNamespaceParams<T>,
{ registry, helpers, client, mappings, extensions = {} }: ApiExecutionContext
): Promise<any> => {
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;
};

View file

@ -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 <T = unknown, A = unknown>(
{ options, internalOptions }: PerformFindParams,
{
registry,
helpers,
allowedTypes: rawAllowedTypes,
mappings,
client,
serializer,
migrator,
extensions = {},
}: ApiExecutionContext
): Promise<SavedObjectsFindResponse<T, A>> => {
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<T, A>(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<T, A>(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<T, A>(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<string, string[]> | undefined;
let authorizationResult: CheckAuthorizationResult<string> | 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<T, A>(options);
}
if (authorizationResult?.status === 'partially_authorized') {
typeToNamespacesMap = new Map<string, string[]>();
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<SavedObjectsRawDocSource>(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<T, A>(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<SavedObjectsRawDocSource>): SavedObjectsFindResult => ({
...serializerHelper.rawToSavedObject(hit as SavedObjectsRawDoc, {
migrationVersionCompatibility,
}),
score: hit._score!,
sort: hit.sort,
})
),
pit_id: body.pit_id,
} as SavedObjectsFindResponse<T, A>;
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
);
};

View file

@ -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 <T>(
{ type, id, options }: PerformGetParams,
{ registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext
): Promise<SavedObject<T>> => {
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<SavedObjectsRawDocSource>(
{
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<T>(registry, type, id, body, {
migrationVersionCompatibility,
});
return encryptionHelper.optionallyDecryptAndRedactSingleResult(
result,
authorizationResult?.typeMap
);
};

View file

@ -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<CommonHelper>;
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)];

View file

@ -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<EncryptionHelper>;
export class EncryptionHelper {
private securityExtension?: ISavedObjectsSecurityExtension;
private encryptionExtension?: ISavedObjectsEncryptionExtension;
constructor({
securityExtension,
encryptionExtension,
}: {
securityExtension?: ISavedObjectsSecurityExtension;
encryptionExtension?: ISavedObjectsEncryptionExtension;
}) {
this.securityExtension = securityExtension;
this.encryptionExtension = encryptionExtension;
}
async optionallyEncryptAttributes<T>(
type: string,
id: string,
namespaceOrNamespaces: string | string[] | undefined,
attributes: T
): Promise<T> {
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<string, unknown>
) as unknown as T;
}
async optionallyDecryptAndRedactSingleResult<T, A extends string>(
object: SavedObject<T>,
typeMap: AuthorizationTypeMap<A> | 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<SavedObject<T>> },
A extends string,
O extends Array<{ attributes: T }>
>(response: R, typeMap: AuthorizationTypeMap<A> | 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 };
}
}

View file

@ -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;
}

View file

@ -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<PreflightCheckHelper>;
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<PreflightCheckNamespacesResult> {
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<SavedObjectsRawDocSource>(
{
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<SavedObjectsRawDocSource>;
}

View file

@ -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<SerializerHelper>;
export class SerializerHelper {
private registry: ISavedObjectTypeRegistry;
private serializer: ISavedObjectsSerializer;
constructor({
registry,
serializer,
}: {
registry: ISavedObjectTypeRegistry;
serializer: ISavedObjectsSerializer;
}) {
this.registry = registry;
this.serializer = serializer;
}
public rawToSavedObject<T = unknown>(
raw: SavedObjectsRawDoc,
options?: SavedObjectsRawDocParseOptions
): SavedObject<T> {
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<T>;
}
}

View file

@ -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<ValidationHelper>;
export class ValidationHelper {
private registry: ISavedObjectTypeRegistry;
private logger: Logger;
private kibanaVersion: string;
private typeValidatorMap: Record<string, SavedObjectsTypeValidator> = {};
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'
);
}
}
}

View file

@ -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<T = unknown> {
type: string;
id: string;
counterFields: Array<string | SavedObjectsIncrementCounterField>;
options: SavedObjectsIncrementCounterOptions<T>;
}
export const performIncrementCounter = async <T>(
{ type, id, counterFields, options }: PerformIncrementCounterParams<T>,
apiExecutionContext: ApiExecutionContext
): Promise<SavedObject<T>> => {
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<string | { incrementBy?: number; fieldName: string }>'
);
}
if (!allowedTypes.includes(type)) {
throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type);
}
return incrementCounterInternal<T>({ type, id, counterFields, options }, apiExecutionContext);
};

View file

@ -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';

View file

@ -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,

View file

@ -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,

View file

@ -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;

View file

@ -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<T = unknown> {
type: string;
id: string;
counterFields: Array<string | SavedObjectsIncrementCounterField>;
options: SavedObjectsIncrementCounterOptions<T>;
}
export const incrementCounterInternal = async <T>(
{ type, id, counterFields, options }: PerformIncrementCounterInternalParams<T>,
{ registry, helpers, client, serializer, migrator }: ApiExecutionContext
): Promise<SavedObject<T>> => {
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<string, number>),
},
migrationVersion,
typeMigrationVersion,
managed,
updated_at: time,
});
const raw = serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc);
const body = await client.update<unknown, unknown, SavedObjectsRawDocSource>({
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 }),
};
};

View file

@ -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';

View file

@ -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,

View file

@ -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';

View file

@ -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: <T = unknown>(
type: string,
@ -271,26 +273,20 @@ function validateObjectTypes(objects: SavedObjectsBulkResolveObject[], allowedTy
return objects.map<Either<BulkResolveError, SavedObjectsBulkResolveObject>>((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<Right<SavedObjectsBulkResolveObject>>,
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<string, number>();

View file

@ -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,

View file

@ -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,

View file

@ -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<Either<ParsedObject>>((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<ParsedObject & { checkAliases: boolean }>
) {

View file

@ -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,
}));

View file

@ -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<UpdateObjectsSpacesParams, 'objects' | 'spacesToAdd' | 'spacesToRemove' | 'options'>

View file

@ -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;

View file

@ -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 <T>(
{ type, options, internalOptions }: PerforOpenPointInTimeParams,
{ helpers, allowedTypes: rawAllowedTypes, client, extensions = {} }: ApiExecutionContext
): Promise<SavedObjectsOpenPointInTimeResponse> => {
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,
};
};

View file

@ -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 }
);
});
});
});

View file

@ -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 <T>(
{ type, id, options }: PerformRemoveReferencesToParams,
{ registry, helpers, client, mappings, extensions = {} }: ApiExecutionContext
): Promise<SavedObjectsRemoveReferencesToResponse> => {
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!,
};
};

View file

@ -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<T = unknown> {
type: string;
id: string;
options: SavedObjectsResolveOptions;
}
export const performResolve = async <T>(
{ type, id, options }: PerformCreateParams<T>,
apiExecutionContext: ApiExecutionContext
): Promise<SavedObjectsResolveResponse<T>> => {
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<T>({
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;
};

View file

@ -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;
}

View file

@ -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<T = unknown> {
type: string;
id: string;
attributes: T;
options: SavedObjectsUpdateOptions<T>;
}
export const performUpdate = async <T>(
{ id, type, attributes, options }: PerformUpdateParams<T>,
{
registry,
helpers,
allowedTypes,
client,
serializer,
migrator,
extensions = {},
}: ApiExecutionContext
): Promise<SavedObjectsUpdateResponse<T>> => {
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<unknown, unknown, SavedObjectsRawDocSource>({
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<T>;
return encryptionHelper.optionallyDecryptAndRedactSingleResult(
result,
authorizationResult?.typeMap,
attributes
);
};

View file

@ -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<T = unknown> {
objects: SavedObjectsUpdateObjectsSpacesObject[];
spacesToAdd: string[];
spacesToRemove: string[];
options: SavedObjectsUpdateObjectsSpacesOptions;
}
export const performUpdateObjectsSpaces = async <T>(
{ objects, spacesToAdd, spacesToRemove, options }: PerformCreateParams<T>,
{
registry,
helpers,
allowedTypes,
client,
serializer,
logger,
mappings,
extensions = {},
}: ApiExecutionContext
): Promise<SavedObjectsUpdateObjectsSpacesResponse> => {
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 },
});
};

View file

@ -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<L = unknown, R = L> = Left<L> | Right<R>;
/**
* Left part of discriminated union ({@link Either}).
* @internal
*/
export interface Left<L> {
tag: 'Left';
value: L;
}
/**
* Right part of discriminated union ({@link Either}).
* @internal
*/
export interface Right<R> {
tag: 'Right';
value: R;
}
/**
* Returns a {@link Left} part holding the provided value.
* @internal
*/
export const left = <L>(value: L): Left<L> => ({
tag: 'Left',
value,
});
/**
* Returns a {@link Right} part holding the provided value.
* @internal
*/
export const right = <R>(value: R): Right<R> => ({
tag: 'Right',
value,
});
/**
* Type guard for left part of discriminated union ({@link Left}, {@link Either}).
* @internal
*/
export const isLeft = <L, R>(either: Either<L, R>): either is Left<L> => either.tag === 'Left';
/**
* Type guard for right part of discriminated union ({@link Right}, {@link Either}).
* @internal
*/
export const isRight = <L, R>(either: Either<L, R>): either is Right<R> => either.tag === 'Right';

View file

@ -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<TDocument = unknown> = estypes.GetResponse<TDocument> &
Required<
Pick<estypes.GetResponse<TDocument>, '_primary_term' | '_seq_no' | '_version' | '_source'>
>;
export const isFoundGetResponse = <TDocument = unknown>(
doc: estypes.GetResponse<TDocument>
): doc is GetResponseFound<TDocument> => doc.found;

View file

@ -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';

View file

@ -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. */

View file

@ -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';

View file

@ -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<L = unknown, R = L> = Left<L> | Right<R>;
/**
* Left part of discriminated union ({@link Either}).
* @internal
*/
export interface Left<L> {
tag: 'Left';
value: L;
}
/**
* Right part of discriminated union ({@link Either}).
* @internal
*/
export interface Right<R> {
tag: 'Right';
value: R;
}
/**
* Type guard for left part of discriminated union ({@link Left}, {@link Either}).
* @internal
*/
export const isLeft = <L, R>(either: Either<L, R>): either is Left<L> => either.tag === 'Left';
/**
* Type guard for right part of discriminated union ({@link Right}, {@link Either}).
* @internal
*/
export const isRight = <L, R>(either: Either<L, R>): either is Right<R> => 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<unknown>): doc is estypes.GetGetResult {
return Boolean(doc && 'found' in doc);
}

View file

@ -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;

View file

@ -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);
});

View file

@ -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<T> {
priority: number;
value: T;
}
export class PriorityCollection<T> {
private readonly array: Array<PriorityCollectionEntry<T>> = [];
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);
}
}

View file

@ -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<typeof internalBulkResolve>;
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<typeof updateObjectsSpaces>;
jest.mock('./update_objects_spaces', () => ({
jest.mock('./apis/internals/update_objects_spaces', () => ({
updateObjectsSpaces: mockUpdateObjectsSpaces,
}));

View file

@ -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';
/**

View file

@ -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<ApiExecutionContext, 'allowedTypes' | 'mappings'> & {
registry: SavedObjectTypeRegistry;
helpers: RepositoryHelpersMock;
extensions: ReturnType<typeof savedObjectsExtensionsMock.create>;
client: ElasticsearchClientMock;
serializer: ReturnType<typeof serializerMock.create>;
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,
};

View file

@ -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<PublicMethodsOf<CommonHelper>>;
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<PublicMethodsOf<EncryptionHelper>>;
const createEncryptionHelperMock = (): EncryptionHelperMock => {
const mock: EncryptionHelperMock = {
optionallyEncryptAttributes: jest.fn(),
optionallyDecryptAndRedactSingleResult: jest.fn(),
optionallyDecryptAndRedactBulkResult: jest.fn(),
};
return mock;
};
export type ValidationHelperMock = jest.Mocked<PublicMethodsOf<ValidationHelper>>;
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<PublicMethodsOf<SerializerHelper>>;
const createSerializerHelperMock = (): SerializerHelperMock => {
const mock: SerializerHelperMock = {
rawToSavedObject: jest.fn(),
};
return mock;
};
export type PreflightCheckHelperMock = jest.Mocked<PublicMethodsOf<PreflightCheckHelper>>;
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,
};

View file

@ -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';

View file

@ -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<IKibanaMigrator>;
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(),
};
};

View file

@ -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,

View file

@ -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/**/*",

View file

@ -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';