diff --git a/docs/api/saved-objects.asciidoc b/docs/api/saved-objects.asciidoc index ba4e5a7e656fc8..0625beb793c311 100644 --- a/docs/api/saved-objects.asciidoc +++ b/docs/api/saved-objects.asciidoc @@ -16,6 +16,8 @@ The following saved objects APIs are available: * <> to retrieve multiple {kib} saved objects by ID +* <> to retrieve multiple {kib} saved objects by ID, using any legacy URL aliases if they exist + * <> to retrieve a paginated set of {kib} saved objects by various conditions * <> to create {kib} saved objects @@ -45,4 +47,5 @@ include::saved-objects/export.asciidoc[] include::saved-objects/import.asciidoc[] include::saved-objects/resolve_import_errors.asciidoc[] include::saved-objects/resolve.asciidoc[] +include::saved-objects/bulk_resolve.asciidoc[] include::saved-objects/rotate_encryption_key.asciidoc[] diff --git a/docs/api/saved-objects/bulk_resolve.asciidoc b/docs/api/saved-objects/bulk_resolve.asciidoc new file mode 100644 index 00000000000000..98077ff11aa8c3 --- /dev/null +++ b/docs/api/saved-objects/bulk_resolve.asciidoc @@ -0,0 +1,176 @@ +[[saved-objects-api-bulk-resolve]] +=== Bulk resolve objects API +++++ +Bulk resolve objects +++++ + +experimental[] Retrieve multiple {kib} saved objects by ID, using any legacy URL aliases if they exist. + +Under certain circumstances, when Kibana is upgraded, saved object migrations may necessitate regenerating some object IDs to enable new +features. When an object's ID is regenerated, a legacy URL alias is created for that object, preserving its old ID. In such a scenario, that +object can be retrieved via the Bulk Resolve API using either its new ID or its old ID. + +[[saved-objects-api-bulk-resolve-request]] +==== Request + +`POST :/api/saved_objects/_bulk_resolve` + +`POST :/s//api/saved_objects/_bulk_resolve` + +[[saved-objects-api-bulk-resolve-path-params]] +==== Path parameters + +`space_id`:: + (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. + +[[saved-objects-api-bulk-resolve-request-body]] +==== Request Body + +`type`:: + (Required, string) Valid options include `visualization`, `dashboard`, `search`, `index-pattern`, `config`. + +`id`:: + (Required, string) ID of the retrieved object. The ID includes the {kib} unique identifier or a custom identifier. + +[[saved-objects-api-bulk-resolve-response-body]] +==== Response body + +`resolved_objects`:: + (array) Top-level property containing objects that represent the response for each of the requested objects. The order of the objects in the response is identical to the order of the objects in the request. + +Saved objects that {kib} fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the outcome is +that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. This behavior is unique to `_bulk_resolve`; the +<> will return only an HTTP error instead. + +[[saved-objects-api-bulk-resolve-body-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[saved-objects-api-bulk-resolve-body-example]] +==== Example + +Retrieve an index pattern with the `my-pattern` ID, and a dashboard with the `my-dashboard` ID: + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/saved_objects/_bulk_resolve +[ + { + "type": "index-pattern", + "id": "my-pattern" + }, + { + "type": "dashboard", + "id": "be3733a0-9efe-11e7-acb3-3dab96693fab" + } +] +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "resolved_objects": [ + { + "saved_object": { + "id": "my-pattern", + "type": "index-pattern", + "version": 1, + "attributes": { + "title": "my-pattern-*" + } + }, + "outcome": "exactMatch" + }, + { + "saved_object": { + "id": "my-dashboard", + "type": "dashboard", + "error": { + "statusCode": 404, + "message": "Not found" + } + }, + "outcome": "exactMatch" + } + ] +} +-------------------------------------------------- + +Only the index pattern exists, the dashboard was not found. + +The `outcome` field may be any of the following: + +* `"exactMatch"` -- One document exactly matched the given ID, *or* {kib} failed to find this object. +* `"aliasMatch"` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. +* `"conflict"` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. + +If the outcome is `"aliasMatch"` or `"conflict"`, the response will also include an `alias_target_id` field. This means that an alias was found for another object, and it describes that other object's ID. + +Retrieve a dashboard object in the `testspace` by ID: + +[source,sh] +-------------------------------------------------- +$ curl -X GET s/testspace/api/saved_objects/resolve/dashboard/7adfa750-4c81-11e8-b3d7-01146121b73d +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "resolved_objects": [ + { + "saved_object": { + "id": "7adfa750-4c81-11e8-b3d7-01146121b73d", + "type": "dashboard", + "updated_at": "2019-07-23T00:11:07.059Z", + "version": "WzQ0LDFd", + "attributes": { + "title": "[Flights] Global Flight Dashboard", + "hits": 0, + "description": "Analyze mock flight data for ES-Air, Logstash Airways, Kibana Airlines and JetBeats", + "panelsJSON": "[ . . . ]", + "optionsJSON": "{\"hidePanelTitles\":false,\"useMargins\":true}", + "version": 1, + "timeRestore": true, + "timeTo": "now", + "timeFrom": "now-24h", + "refreshInterval": { + "display": "15 minutes", + "pause": false, + "section": 2, + "value": 900000 + }, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + } + }, + "references": [ + { + "name": "panel_0", + "type": "visualization", + "id": "aeb212e0-4c84-11e8-b3d7-01146121b73d" + }, + . . . + { + "name": "panel_18", + "type": "visualization", + "id": "ed78a660-53a0-11e8-acbd-0be0ad9d822b" + } + ], + "migrationVersion": { + "dashboard": "7.0.0" + } + }, + "outcome": "conflict", + "alias_target_id": "05becb88-e214-439a-a2ac-15fc783b5d01" + } + ] +} +-------------------------------------------------- diff --git a/docs/developer/advanced/sharing-saved-objects.asciidoc b/docs/developer/advanced/sharing-saved-objects.asciidoc index 19c1b806572810..06019735188aac 100644 --- a/docs/developer/advanced/sharing-saved-objects.asciidoc +++ b/docs/developer/advanced/sharing-saved-objects.asciidoc @@ -412,7 +412,7 @@ deprecate and remove them. [[sharing-saved-objects-faq-resolve-outcomes]] ==== 5. Why are there three different resolve outcomes? -The `resolve()` function first checks if an object with the given ID exists, and then it checks if an object has an alias with the given ID. +The `resolve()` function checks both if an object with the given ID exists, _and_ if an object has an alias with the given ID. 1. If only the former is true, the outcome is an `'exactMatch'` -- we found the exact object we were looking for. 2. If only the latter is true, the outcome is an `'aliasMatch'` -- we found an alias with this ID, that pointed us to an object with a diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 59735b053adbc2..08c3c376df4e83 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -107,6 +107,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsBatchResponse](./kibana-plugin-core-public.savedobjectsbatchresponse.md) | | | [SavedObjectsBulkCreateObject](./kibana-plugin-core-public.savedobjectsbulkcreateobject.md) | | | [SavedObjectsBulkCreateOptions](./kibana-plugin-core-public.savedobjectsbulkcreateoptions.md) | | +| [SavedObjectsBulkResolveObject](./kibana-plugin-core-public.savedobjectsbulkresolveobject.md) | | +| [SavedObjectsBulkResolveResponse](./kibana-plugin-core-public.savedobjectsbulkresolveresponse.md) | | | [SavedObjectsBulkUpdateObject](./kibana-plugin-core-public.savedobjectsbulkupdateobject.md) | | | [SavedObjectsBulkUpdateOptions](./kibana-plugin-core-public.savedobjectsbulkupdateoptions.md) | | | [SavedObjectsCollectMultiNamespaceReferencesResponse](./kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.md) | The response when object references are collected. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveobject.id.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveobject.id.md new file mode 100644 index 00000000000000..6a8fd52a4dc491 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveobject.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsBulkResolveObject](./kibana-plugin-core-public.savedobjectsbulkresolveobject.md) > [id](./kibana-plugin-core-public.savedobjectsbulkresolveobject.id.md) + +## SavedObjectsBulkResolveObject.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveobject.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveobject.md new file mode 100644 index 00000000000000..8ca5da9d7db4fc --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveobject.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsBulkResolveObject](./kibana-plugin-core-public.savedobjectsbulkresolveobject.md) + +## SavedObjectsBulkResolveObject interface + + +Signature: + +```typescript +export interface SavedObjectsBulkResolveObject +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-public.savedobjectsbulkresolveobject.id.md) | string | | +| [type](./kibana-plugin-core-public.savedobjectsbulkresolveobject.type.md) | string | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveobject.type.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveobject.type.md new file mode 100644 index 00000000000000..09c7991012da8c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveobject.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsBulkResolveObject](./kibana-plugin-core-public.savedobjectsbulkresolveobject.md) > [type](./kibana-plugin-core-public.savedobjectsbulkresolveobject.type.md) + +## SavedObjectsBulkResolveObject.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveresponse.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveresponse.md new file mode 100644 index 00000000000000..36a92d02b8aaa1 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveresponse.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsBulkResolveResponse](./kibana-plugin-core-public.savedobjectsbulkresolveresponse.md) + +## SavedObjectsBulkResolveResponse interface + + +Signature: + +```typescript +export interface SavedObjectsBulkResolveResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [resolved\_objects](./kibana-plugin-core-public.savedobjectsbulkresolveresponse.resolved_objects.md) | Array<SavedObjectsResolveResponse<T>> | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveresponse.resolved_objects.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveresponse.resolved_objects.md new file mode 100644 index 00000000000000..3597a771efe082 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsbulkresolveresponse.resolved_objects.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsBulkResolveResponse](./kibana-plugin-core-public.savedobjectsbulkresolveresponse.md) > [resolved\_objects](./kibana-plugin-core-public.savedobjectsbulkresolveresponse.resolved_objects.md) + +## SavedObjectsBulkResolveResponse.resolved\_objects property + +Signature: + +```typescript +resolved_objects: Array>; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkresolve.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkresolve.md new file mode 100644 index 00000000000000..8a03f0e38e0d93 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.bulkresolve.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) > [bulkResolve](./kibana-plugin-core-public.savedobjectsclient.bulkresolve.md) + +## SavedObjectsClient.bulkResolve property + +Resolves an array of objects by id, using any legacy URL aliases if they exist + +Signature: + +```typescript +bulkResolve: (objects?: Array<{ + id: string; + type: string; + }>) => Promise<{ + resolved_objects: ResolvedSimpleSavedObject[]; + }>; +``` + +## Example + +bulkResolve(\[ { id: 'one', type: 'config' }, { id: 'foo', type: 'index-pattern' } \]) + + Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the outcome is that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. The `resolve` method in the public client uses `bulkResolve` under the hood, so it behaves the same way. + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md index aacda031003c6c..1a630ebe8c9ae1 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md @@ -22,6 +22,7 @@ The constructor for this class is marked as internal. Third-party code should no | --- | --- | --- | --- | | [bulkCreate](./kibana-plugin-core-public.savedobjectsclient.bulkcreate.md) | | (objects?: SavedObjectsBulkCreateObject[], options?: SavedObjectsBulkCreateOptions) => Promise<SavedObjectsBatchResponse<unknown>> | Creates multiple documents at once | | [bulkGet](./kibana-plugin-core-public.savedobjectsclient.bulkget.md) | | (objects?: Array<{
id: string;
type: string;
}>) => Promise<SavedObjectsBatchResponse<unknown>> | Returns an array of objects by id | +| [bulkResolve](./kibana-plugin-core-public.savedobjectsclient.bulkresolve.md) | | <T = unknown>(objects?: Array<{
id: string;
type: string;
}>) => Promise<{
resolved_objects: ResolvedSimpleSavedObject<T>[];
}> | Resolves an array of objects by id, using any legacy URL aliases if they exist | | [create](./kibana-plugin-core-public.savedobjectsclient.create.md) | | <T = unknown>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>> | Persists an object | | [delete](./kibana-plugin-core-public.savedobjectsclient.delete.md) | | (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType<SavedObjectsApi['delete']> | Deletes an object | | [find](./kibana-plugin-core-public.savedobjectsclient.find.md) | | <T = unknown, A = unknown>(options: SavedObjectsFindOptions) => Promise<SavedObjectsFindResponsePublic<T, unknown>> | Search for objects | diff --git a/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md index 7b2cbdecd146a8..2bc7f6cba594d3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.deprecationsservicesetup.md @@ -27,6 +27,7 @@ async function getDeprecations({ esClient, savedObjectsClient }: GetDeprecations const deprecations: DeprecationsDetails[] = []; const count = await getFooCount(savedObjectsClient); if (count > 0) { + // Example of a manual correctiveAction deprecations.push({ title: i18n.translate('xpack.foo.deprecations.title', { defaultMessage: `Foo's are deprecated` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 66c0299669dc46..89203cb94d573b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -146,6 +146,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsBaseOptions](./kibana-plugin-core-server.savedobjectsbaseoptions.md) | | | [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) | | | [SavedObjectsBulkGetObject](./kibana-plugin-core-server.savedobjectsbulkgetobject.md) | | +| [SavedObjectsBulkResolveObject](./kibana-plugin-core-server.savedobjectsbulkresolveobject.md) | | +| [SavedObjectsBulkResolveResponse](./kibana-plugin-core-server.savedobjectsbulkresolveresponse.md) | | | [SavedObjectsBulkResponse](./kibana-plugin-core-server.savedobjectsbulkresponse.md) | | | [SavedObjectsBulkUpdateObject](./kibana-plugin-core-server.savedobjectsbulkupdateobject.md) | | | [SavedObjectsBulkUpdateOptions](./kibana-plugin-core-server.savedobjectsbulkupdateoptions.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveobject.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveobject.id.md new file mode 100644 index 00000000000000..135848191cff74 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveobject.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkResolveObject](./kibana-plugin-core-server.savedobjectsbulkresolveobject.md) > [id](./kibana-plugin-core-server.savedobjectsbulkresolveobject.id.md) + +## SavedObjectsBulkResolveObject.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveobject.md new file mode 100644 index 00000000000000..3960511b21434b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveobject.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkResolveObject](./kibana-plugin-core-server.savedobjectsbulkresolveobject.md) + +## SavedObjectsBulkResolveObject interface + + +Signature: + +```typescript +export interface SavedObjectsBulkResolveObject +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectsbulkresolveobject.id.md) | string | | +| [type](./kibana-plugin-core-server.savedobjectsbulkresolveobject.type.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveobject.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveobject.type.md new file mode 100644 index 00000000000000..790edde7fe0790 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveobject.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkResolveObject](./kibana-plugin-core-server.savedobjectsbulkresolveobject.md) > [type](./kibana-plugin-core-server.savedobjectsbulkresolveobject.type.md) + +## SavedObjectsBulkResolveObject.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveresponse.md new file mode 100644 index 00000000000000..8384ecc1861f42 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveresponse.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkResolveResponse](./kibana-plugin-core-server.savedobjectsbulkresolveresponse.md) + +## SavedObjectsBulkResolveResponse interface + + +Signature: + +```typescript +export interface SavedObjectsBulkResolveResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [resolved\_objects](./kibana-plugin-core-server.savedobjectsbulkresolveresponse.resolved_objects.md) | Array<SavedObjectsResolveResponse<T>> | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveresponse.resolved_objects.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveresponse.resolved_objects.md new file mode 100644 index 00000000000000..4d11b146fd848e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkresolveresponse.resolved_objects.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkResolveResponse](./kibana-plugin-core-server.savedobjectsbulkresolveresponse.md) > [resolved\_objects](./kibana-plugin-core-server.savedobjectsbulkresolveresponse.resolved_objects.md) + +## SavedObjectsBulkResolveResponse.resolved\_objects property + +Signature: + +```typescript +resolved_objects: Array>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.bulkresolve.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.bulkresolve.md new file mode 100644 index 00000000000000..0525b361ebecf9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.bulkresolve.md @@ -0,0 +1,31 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [bulkResolve](./kibana-plugin-core-server.savedobjectsclient.bulkresolve.md) + +## SavedObjectsClient.bulkResolve() method + +Resolves an array of objects by id, using any legacy URL aliases if they exist + +Signature: + +```typescript +bulkResolve(objects: SavedObjectsBulkResolveObject[], options?: SavedObjectsBaseOptions): Promise>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| objects | SavedObjectsBulkResolveObject[] | an array of objects containing id, type | +| options | SavedObjectsBaseOptions | | + +Returns: + +`Promise>` + +## Example + +bulkResolve(\[ { id: 'one', type: 'config' }, { id: 'foo', type: 'index-pattern' } \]) + + Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the outcome is that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. This behavior is unique to `bulkResolve`; the regular `resolve` API will throw an error instead. + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index 2e293889b17944..e92b6d8e151b1c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -27,6 +27,7 @@ The constructor for this class is marked as internal. Third-party code should no | --- | --- | --- | | [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkcreate.md) | | Persists multiple documents batched together as a single request | | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id | +| [bulkResolve(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkresolve.md) | | Resolves an array of objects by id, using any legacy URL aliases if they exist | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once | | [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsclient.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | | [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsclient.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using [SavedObjectsClient.openPointInTimeForType()](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md).Only use this API if you have an advanced use case that's not solved by the [SavedObjectsClient.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md) method. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.bulkresolve.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.bulkresolve.md new file mode 100644 index 00000000000000..f489972207a61c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.bulkresolve.md @@ -0,0 +1,31 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [bulkResolve](./kibana-plugin-core-server.savedobjectsrepository.bulkresolve.md) + +## SavedObjectsRepository.bulkResolve() method + +Resolves an array of objects by id, using any legacy URL aliases if they exist + +Signature: + +```typescript +bulkResolve(objects: SavedObjectsBulkResolveObject[], options?: SavedObjectsBaseOptions): Promise>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| objects | SavedObjectsBulkResolveObject[] | | +| options | SavedObjectsBaseOptions | | + +Returns: + +`Promise>` + +{promise} - { resolved\_objects: \[{ saved\_object, outcome }\] } + +## Example + +bulkResolve(\[ { id: 'one', type: 'config' }, { id: 'foo', type: 'index-pattern' } \]) + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 191b125ef3f74b..b1d65f5f6d3c39 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -17,6 +17,7 @@ export declare class SavedObjectsRepository | --- | --- | --- | | [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkcreate.md) | | Creates multiple documents at once | | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkget.md) | | Returns an array of objects by id | +| [bulkResolve(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkresolve.md) | | Resolves an array of objects by id, using any legacy URL aliases if they exist | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkupdate.md) | | Updates multiple objects in bulk | | [checkConflicts(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md) | | Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. | | [closePointInTime(id, options)](./kibana-plugin-core-server.savedobjectsrepository.closepointintime.md) | | Closes a Point In Time (PIT) by ID. This simply proxies the request to ES via the Elasticsearch client, and is included in the Saved Objects Client as a convenience for consumers who are using openPointInTimeForType.Only use this API if you have an advanced use case that's not solved by the [SavedObjectsRepository.createPointInTimeFinder()](./kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md) method. | diff --git a/src/core/public/index.ts b/src/core/public/index.ts index d343a0b081fa1a..b2d3d21a09999c 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -103,6 +103,8 @@ export type { SavedObjectsBatchResponse, SavedObjectsBulkCreateObject, SavedObjectsBulkCreateOptions, + SavedObjectsBulkResolveObject, + SavedObjectsBulkResolveResponse, SavedObjectsBulkUpdateObject, SavedObjectsBulkUpdateOptions, SavedObjectsCreateOptions, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 8d3291d5904760..eace9c40119424 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1253,6 +1253,20 @@ export interface SavedObjectsBulkCreateOptions { overwrite?: boolean; } +// @public (undocumented) +export interface SavedObjectsBulkResolveObject { + // (undocumented) + id: string; + // (undocumented) + type: string; +} + +// @public (undocumented) +export interface SavedObjectsBulkResolveResponse { + // (undocumented) + resolved_objects: Array>; +} + // @public (undocumented) export interface SavedObjectsBulkUpdateObject { // (undocumented) @@ -1282,6 +1296,12 @@ export class SavedObjectsClient { id: string; type: string; }>) => Promise>; + bulkResolve: (objects?: Array<{ + id: string; + type: string; + }>) => Promise<{ + resolved_objects: ResolvedSimpleSavedObject[]; + }>; bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; create: (type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise>; // Warning: (ae-forgotten-export) The symbol "SavedObjectsDeleteOptions" needs to be exported by the entry point index.d.ts diff --git a/src/core/public/saved_objects/index.ts b/src/core/public/saved_objects/index.ts index bd22947b174b79..404bc9d40c7d22 100644 --- a/src/core/public/saved_objects/index.ts +++ b/src/core/public/saved_objects/index.ts @@ -17,7 +17,6 @@ export type { SavedObjectsCreateOptions, SavedObjectsFindResponsePublic, SavedObjectsUpdateOptions, - SavedObjectsResolveResponse, SavedObjectsBulkUpdateOptions, } from './saved_objects_client'; export { SimpleSavedObject } from './simple_saved_object'; @@ -43,7 +42,10 @@ export type { SavedObjectsImportWarning, SavedObjectReferenceWithContext, SavedObjectsCollectMultiNamespaceReferencesResponse, -} from '../../server/types'; + SavedObjectsBulkResolveObject, + SavedObjectsBulkResolveResponse, + SavedObjectsResolveResponse, +} from '../../server'; export type { SavedObject, diff --git a/src/core/public/saved_objects/saved_objects_client.test.ts b/src/core/public/saved_objects/saved_objects_client.test.ts index 2eed9615430e9f..0f37d10b3f32d9 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import type { SavedObjectsResolveResponse } from 'src/core/server'; - import { SavedObjectsClient } from './saved_objects_client'; import { SimpleSavedObject } from './simple_saved_object'; import { httpServiceMock } from '../http/http_service.mock'; @@ -151,36 +149,61 @@ describe('SavedObjectsClient', () => { describe('#resolve', () => { beforeEach(() => { - beforeEach(() => { - http.fetch.mockResolvedValue({ - saved_object: doc, - outcome: 'conflict', - alias_target_id: 'another-id', - } as SavedObjectsResolveResponse); + http.fetch.mockResolvedValue({ + resolved_objects: [ + { saved_object: doc, outcome: 'conflict', alias_target_id: 'another-id' }, + ], }); }); - test('rejects if `type` is undefined', async () => { - expect(savedObjectsClient.resolve(undefined as any, doc.id)).rejects.toMatchInlineSnapshot( - `[Error: requires type and id]` - ); + test('rejects if `type` parameter is undefined', () => { + return expect( + savedObjectsClient.resolve(undefined as any, undefined as any) + ).rejects.toMatchInlineSnapshot(`[Error: requires type and id]`); }); - test('rejects if `id` is undefined', async () => { - expect(savedObjectsClient.resolve(doc.type, undefined as any)).rejects.toMatchInlineSnapshot( - `[Error: requires type and id]` + test('rejects if `id` parameter is undefined', () => { + return expect( + savedObjectsClient.resolve('index-pattern', undefined as any) + ).rejects.toMatchInlineSnapshot(`[Error: requires type and id]`); + }); + + test('rejects when HTTP call fails', () => { + http.fetch.mockRejectedValue(new Error('Request failed')); + return expect(savedObjectsClient.resolve(doc.type, doc.id)).rejects.toMatchInlineSnapshot( + `[Error: Request failed]` ); }); - test('makes HTTP call', () => { - savedObjectsClient.resolve(doc.type, doc.id); + test('makes HTTP call', async () => { + await savedObjectsClient.resolve(doc.type, doc.id); + expect(http.fetch.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/saved_objects/_bulk_resolve", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\"}]", + "method": "POST", + "query": undefined, + }, + ] + `); + }); + + test('batches several #resolve calls into a single HTTP call', async () => { + // Await #resolve call to ensure batchQueue is empty and throttle has reset + await savedObjectsClient.resolve('type2', doc.id); + http.fetch.mockClear(); + + // Make two #resolve calls right after one another + savedObjectsClient.resolve('type1', doc.id); + await savedObjectsClient.resolve('type0', doc.id); expect(http.fetch.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "/api/saved_objects/resolve/config/AVwSwFxtcMV38qjDZoQg", + "/api/saved_objects/_bulk_resolve", Object { - "body": undefined, - "method": undefined, + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"type1\\"},{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"type0\\"}]", + "method": "POST", "query": undefined, }, ], @@ -188,11 +211,55 @@ describe('SavedObjectsClient', () => { `); }); - test('rejects when HTTP call fails', async () => { - http.fetch.mockRejectedValueOnce(new Error('Request failed')); - await expect(savedObjectsClient.resolve(doc.type, doc.id)).rejects.toMatchInlineSnapshot( - `[Error: Request failed]` - ); + test('removes duplicates when calling `_bulk_resolve`', async () => { + // Await #resolve call to ensure batchQueue is empty and throttle has reset + await savedObjectsClient.resolve('type2', doc.id); + http.fetch.mockClear(); + + savedObjectsClient.resolve(doc.type, doc.id); + savedObjectsClient.resolve('some-type', 'some-id'); + await savedObjectsClient.resolve(doc.type, doc.id); + + expect(http.fetch).toHaveBeenCalledTimes(1); + expect(http.fetch.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/saved_objects/_bulk_resolve", + Object { + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\"},{\\"id\\":\\"some-id\\",\\"type\\":\\"some-type\\"}]", + "method": "POST", + "query": undefined, + }, + ] + `); + }); + + test('resolves with correct object when there are duplicates present', async () => { + // Await #resolve call to ensure batchQueue is empty and throttle has reset + await savedObjectsClient.resolve('type2', doc.id); + http.fetch.mockClear(); + + const call1 = savedObjectsClient.resolve(doc.type, doc.id); + const objFromCall2 = await savedObjectsClient.resolve(doc.type, doc.id); + const objFromCall1 = await call1; + + expect(objFromCall1.saved_object.type).toBe(doc.type); + expect(objFromCall1.saved_object.id).toBe(doc.id); + + expect(objFromCall2.saved_object.type).toBe(doc.type); + expect(objFromCall2.saved_object.id).toBe(doc.id); + }); + + test('do not share instances or references between duplicate callers', async () => { + // Await #resolve call to ensure batchQueue is empty and throttle has reset + await savedObjectsClient.resolve('type2', doc.id); + http.fetch.mockClear(); + + const call1 = savedObjectsClient.resolve(doc.type, doc.id); + const objFromCall2 = await savedObjectsClient.resolve(doc.type, doc.id); + const objFromCall1 = await call1; + + objFromCall1.saved_object.set('title', 'new title'); + expect(objFromCall2.saved_object.get('title')).toEqual('Example title'); }); test('resolves with ResolvedSimpleSavedObject instance', async () => { diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 2e4c25035daced..218ffb94dd5d4a 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -12,6 +12,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { SavedObject, SavedObjectReference, + SavedObjectsBulkResolveResponse, SavedObjectsClientContract as SavedObjectsApi, SavedObjectsFindOptions as SavedObjectFindOptionsServer, SavedObjectsMigrationVersion, @@ -22,8 +23,6 @@ import { SimpleSavedObject } from './simple_saved_object'; import type { ResolvedSimpleSavedObject } from './types'; import { HttpFetchOptions, HttpSetup } from '../http'; -export type { SavedObjectsResolveResponse }; - type PromiseType> = T extends Promise ? U : never; type SavedObjectsFindOptions = Omit< @@ -113,12 +112,18 @@ export interface SavedObjectsFindResponsePublic page: number; } -interface BatchQueueEntry { +interface BatchGetQueueEntry { type: string; id: string; resolve: (value: SimpleSavedObject | SavedObject) => void; reject: (reason?: any) => void; } +interface BatchResolveQueueEntry { + type: string; + id: string; + resolve: (value: ResolvedSimpleSavedObject) => void; + reject: (reason?: any) => void; +} const joinUriComponents = (...uriComponents: Array) => uriComponents @@ -146,7 +151,9 @@ interface ObjectTypeAndId { type: string; } -const getObjectsToFetch = (queue: BatchQueueEntry[]): ObjectTypeAndId[] => { +const getObjectsToFetch = ( + queue: Array +): ObjectTypeAndId[] => { const objects: ObjectTypeAndId[] = []; const inserted = new Set(); queue.forEach(({ id, type }) => { @@ -168,15 +175,16 @@ const getObjectsToFetch = (queue: BatchQueueEntry[]): ObjectTypeAndId[] => { */ export class SavedObjectsClient { private http: HttpSetup; - private batchQueue: BatchQueueEntry[]; + private batchGetQueue: BatchGetQueueEntry[]; + private batchResolveQueue: BatchResolveQueueEntry[]; /** * Throttled processing of get requests into bulk requests at 100ms interval */ - private processBatchQueue = throttle( + private processBatchGetQueue = throttle( async () => { - const queue = [...this.batchQueue]; - this.batchQueue = []; + const queue = [...this.batchGetQueue]; + this.batchGetQueue = []; try { const objectsToFetch = getObjectsToFetch(queue); @@ -207,10 +215,53 @@ export class SavedObjectsClient { { leading: false } ); + /** + * Throttled processing of resolve requests into bulk requests at 100ms interval + */ + private processBatchResolveQueue = throttle( + async () => { + const queue = [...this.batchResolveQueue]; + this.batchResolveQueue = []; + + try { + const objectsToFetch = getObjectsToFetch(queue); + const { resolved_objects: savedObjects } = await this.performBulkResolve(objectsToFetch); + + queue.forEach((queueItem) => { + const foundObject = savedObjects.find((resolveResponse) => { + return ( + resolveResponse.saved_object.id === queueItem.id && + resolveResponse.saved_object.type === queueItem.type + ); + }); + + if (foundObject) { + // multiple calls may have been requested the same object. + // we need to clone to avoid sharing references between the instances + queueItem.resolve(this.createResolvedSavedObject(cloneDeep(foundObject))); + } else { + queueItem.resolve( + this.createResolvedSavedObject({ + saved_object: pick(queueItem, ['id', 'type']), + } as SavedObjectsResolveResponse) + ); + } + }); + } catch (err) { + queue.forEach((queueItem) => { + queueItem.reject(err); + }); + } + }, + BATCH_INTERVAL, + { leading: false } + ); + /** @internal */ constructor(http: HttpSetup) { this.http = http; - this.batchQueue = []; + this.batchGetQueue = []; + this.batchResolveQueue = []; } /** @@ -387,8 +438,8 @@ export class SavedObjectsClient { } return new Promise((resolve, reject) => { - this.batchQueue.push({ type, id, resolve, reject } as BatchQueueEntry); - this.processBatchQueue(); + this.batchGetQueue.push({ type, id, resolve, reject } as BatchGetQueueEntry); + this.processBatchGetQueue(); }); }; @@ -430,6 +481,11 @@ export class SavedObjectsClient { * @param {string} type * @param {string} id * @returns The resolve result for the saved object for the given type and id. + * + * @note Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the + * outcome is that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. This behavior for the `resolve` + * API is unique to the public client, which batches individual calls with `bulkResolve` under the hood. We don't throw an error in that + * case for legacy compatibility reasons. */ public resolve = ( type: string, @@ -439,18 +495,47 @@ export class SavedObjectsClient { return Promise.reject(new Error('requires type and id')); } - const path = `${this.getPath(['resolve'])}/${type}/${id}`; - const request: Promise> = this.savedObjectsFetch(path, {}); - return request.then((resolveResponse) => { - const simpleSavedObject = new SimpleSavedObject(this, resolveResponse.saved_object); - return { - saved_object: simpleSavedObject, - outcome: resolveResponse.outcome, - alias_target_id: resolveResponse.alias_target_id, - }; + return new Promise((resolve, reject) => { + this.batchResolveQueue.push({ type, id, resolve, reject } as BatchResolveQueueEntry); + this.processBatchResolveQueue(); }); }; + /** + * Resolves an array of objects by id, using any legacy URL aliases if they exist + * + * @param objects - an array of objects containing id, type + * @returns The bulk resolve result for the saved objects for the given types and ids. + * @example + * + * bulkResolve([ + * { id: 'one', type: 'config' }, + * { id: 'foo', type: 'index-pattern' } + * ]) + * + * @note Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the + * outcome is that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. The `resolve` method in the + * public client uses `bulkResolve` under the hood, so it behaves the same way. + */ + public bulkResolve = async (objects: Array<{ id: string; type: string }> = []) => { + const filteredObjects = objects.map(({ type, id }) => ({ type, id })); + const response = await this.performBulkResolve(filteredObjects); + return { + resolved_objects: response.resolved_objects.map((resolveResponse) => + this.createResolvedSavedObject(resolveResponse) + ), + }; + }; + + private async performBulkResolve(objects: ObjectTypeAndId[]) { + const path = this.getPath(['_bulk_resolve']); + const request: Promise> = this.savedObjectsFetch(path, { + method: 'POST', + body: JSON.stringify(objects), + }); + return request; + } + /** * Updates an object * @@ -513,6 +598,17 @@ export class SavedObjectsClient { return new SimpleSavedObject(this, options); } + private createResolvedSavedObject( + resolveResponse: SavedObjectsResolveResponse + ): ResolvedSimpleSavedObject { + const simpleSavedObject = new SimpleSavedObject(this, resolveResponse.saved_object); + return { + saved_object: simpleSavedObject, + outcome: resolveResponse.outcome, + alias_target_id: resolveResponse.alias_target_id, + }; + } + private getPath(path: Array): string { return API_BASE_URL + joinUriComponents(...path); } diff --git a/src/core/public/saved_objects/saved_objects_service.mock.ts b/src/core/public/saved_objects/saved_objects_service.mock.ts index 2ceef1c077c394..8e483094eed4b3 100644 --- a/src/core/public/saved_objects/saved_objects_service.mock.ts +++ b/src/core/public/saved_objects/saved_objects_service.mock.ts @@ -13,6 +13,7 @@ const createStartContractMock = () => { client: { create: jest.fn(), bulkCreate: jest.fn(), + bulkResolve: jest.fn(), bulkUpdate: jest.fn(), delete: jest.fn(), bulkGet: jest.fn(), diff --git a/src/core/server/core_usage_data/core_usage_stats_client.mock.ts b/src/core/server/core_usage_data/core_usage_stats_client.mock.ts index 35471234676b1e..438424f76d057b 100644 --- a/src/core/server/core_usage_data/core_usage_stats_client.mock.ts +++ b/src/core/server/core_usage_data/core_usage_stats_client.mock.ts @@ -13,6 +13,7 @@ const createUsageStatsClientMock = () => getUsageStats: jest.fn().mockResolvedValue({}), incrementSavedObjectsBulkCreate: jest.fn().mockResolvedValue(null), incrementSavedObjectsBulkGet: jest.fn().mockResolvedValue(null), + incrementSavedObjectsBulkResolve: jest.fn().mockResolvedValue(null), incrementSavedObjectsBulkUpdate: jest.fn().mockResolvedValue(null), incrementSavedObjectsCreate: jest.fn().mockResolvedValue(null), incrementSavedObjectsDelete: jest.fn().mockResolvedValue(null), diff --git a/src/core/server/core_usage_data/core_usage_stats_client.test.ts b/src/core/server/core_usage_data/core_usage_stats_client.test.ts index d14c248bfa1b72..6bcaa38bd00620 100644 --- a/src/core/server/core_usage_data/core_usage_stats_client.test.ts +++ b/src/core/server/core_usage_data/core_usage_stats_client.test.ts @@ -27,6 +27,7 @@ import { EXPORT_STATS_PREFIX, LEGACY_DASHBOARDS_IMPORT_STATS_PREFIX, LEGACY_DASHBOARDS_EXPORT_STATS_PREFIX, + BULK_RESOLVE_STATS_PREFIX, } from './core_usage_stats_client'; import { CoreUsageStatsClient } from '.'; import { DEFAULT_NAMESPACE_STRING } from '../saved_objects/service/lib/utils'; @@ -222,6 +223,81 @@ describe('CoreUsageStatsClient', () => { }); }); + describe('#incrementSavedObjectsBulkResolve', () => { + it('does not throw an error if repository incrementCounter operation fails', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); + + const request = httpServerMock.createKibanaRequest(); + await expect( + usageStatsClient.incrementSavedObjectsBulkResolve({ + request, + } as BaseIncrementOptions) + ).resolves.toBeUndefined(); + expect(repositoryMock.incrementCounter).toHaveBeenCalled(); + }); + + it('handles falsy options appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + const request = httpServerMock.createKibanaRequest(); + await usageStatsClient.incrementSavedObjectsBulkResolve({ + request, + } as BaseIncrementOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${BULK_RESOLVE_STATS_PREFIX}.total`, + `${BULK_RESOLVE_STATS_PREFIX}.namespace.default.total`, + `${BULK_RESOLVE_STATS_PREFIX}.namespace.default.kibanaRequest.no`, + ], + incrementOptions + ); + }); + + it('handles truthy options and the default namespace string appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup(DEFAULT_NAMESPACE_STRING); + + const request = httpServerMock.createKibanaRequest({ headers: firstPartyRequestHeaders }); + await usageStatsClient.incrementSavedObjectsBulkResolve({ + request, + } as BaseIncrementOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${BULK_RESOLVE_STATS_PREFIX}.total`, + `${BULK_RESOLVE_STATS_PREFIX}.namespace.default.total`, + `${BULK_RESOLVE_STATS_PREFIX}.namespace.default.kibanaRequest.yes`, + ], + incrementOptions + ); + }); + + it('handles a non-default space appropriately', async () => { + const { usageStatsClient, repositoryMock } = setup('foo'); + + const request = httpServerMock.createKibanaRequest(); + await usageStatsClient.incrementSavedObjectsBulkResolve({ + request, + } as BaseIncrementOptions); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [ + `${BULK_RESOLVE_STATS_PREFIX}.total`, + `${BULK_RESOLVE_STATS_PREFIX}.namespace.custom.total`, + `${BULK_RESOLVE_STATS_PREFIX}.namespace.custom.kibanaRequest.no`, + ], + incrementOptions + ); + }); + }); + describe('#incrementSavedObjectsBulkUpdate', () => { it('does not throw an error if repository incrementCounter operation fails', async () => { const { usageStatsClient, repositoryMock } = setup(); diff --git a/src/core/server/core_usage_data/core_usage_stats_client.ts b/src/core/server/core_usage_data/core_usage_stats_client.ts index fb5340f164207c..2dd8c77fd18762 100644 --- a/src/core/server/core_usage_data/core_usage_stats_client.ts +++ b/src/core/server/core_usage_data/core_usage_stats_client.ts @@ -35,6 +35,7 @@ export type IncrementSavedObjectsExportOptions = BaseIncrementOptions & { export const BULK_CREATE_STATS_PREFIX = 'apiCalls.savedObjectsBulkCreate'; export const BULK_GET_STATS_PREFIX = 'apiCalls.savedObjectsBulkGet'; +export const BULK_RESOLVE_STATS_PREFIX = 'apiCalls.savedObjectsBulkResolve'; export const BULK_UPDATE_STATS_PREFIX = 'apiCalls.savedObjectsBulkUpdate'; export const CREATE_STATS_PREFIX = 'apiCalls.savedObjectsCreate'; export const DELETE_STATS_PREFIX = 'apiCalls.savedObjectsDelete'; @@ -59,6 +60,7 @@ const ALL_COUNTER_FIELDS = [ // Saved Objects Client APIs ...getFieldsForCounter(BULK_CREATE_STATS_PREFIX), ...getFieldsForCounter(BULK_GET_STATS_PREFIX), + ...getFieldsForCounter(BULK_RESOLVE_STATS_PREFIX), ...getFieldsForCounter(BULK_UPDATE_STATS_PREFIX), ...getFieldsForCounter(CREATE_STATS_PREFIX), ...getFieldsForCounter(DELETE_STATS_PREFIX), @@ -123,6 +125,10 @@ export class CoreUsageStatsClient { await this.updateUsageStats([], BULK_GET_STATS_PREFIX, options); } + public async incrementSavedObjectsBulkResolve(options: BaseIncrementOptions) { + await this.updateUsageStats([], BULK_RESOLVE_STATS_PREFIX, options); + } + public async incrementSavedObjectsBulkUpdate(options: BaseIncrementOptions) { await this.updateUsageStats([], BULK_UPDATE_STATS_PREFIX, options); } diff --git a/src/core/server/core_usage_data/types.ts b/src/core/server/core_usage_data/types.ts index 006f9848e8f3e1..59e220fac4efe0 100644 --- a/src/core/server/core_usage_data/types.ts +++ b/src/core/server/core_usage_data/types.ts @@ -31,6 +31,13 @@ export interface CoreUsageStats { 'apiCalls.savedObjectsBulkGet.namespace.custom.total'?: number; 'apiCalls.savedObjectsBulkGet.namespace.custom.kibanaRequest.yes'?: number; 'apiCalls.savedObjectsBulkGet.namespace.custom.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsBulkResolve.total'?: number; + 'apiCalls.savedObjectsBulkResolve.namespace.default.total'?: number; + 'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.no'?: number; + 'apiCalls.savedObjectsBulkResolve.namespace.custom.total'?: number; + 'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.yes'?: number; + 'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.no'?: number; 'apiCalls.savedObjectsBulkUpdate.total'?: number; 'apiCalls.savedObjectsBulkUpdate.namespace.default.total'?: number; 'apiCalls.savedObjectsBulkUpdate.namespace.default.kibanaRequest.yes'?: number; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 345c95c8d07739..110ac4d5bd973c 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -311,6 +311,8 @@ export type { SavedObjectUnsanitizedDoc, SavedObjectsRepositoryFactory, SavedObjectsResolveImportErrorsOptions, + SavedObjectsBulkResolveObject, + SavedObjectsBulkResolveResponse, SavedObjectsResolveResponse, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, diff --git a/src/core/server/saved_objects/routes/bulk_resolve.ts b/src/core/server/saved_objects/routes/bulk_resolve.ts new file mode 100644 index 00000000000000..493f76a2c497c8 --- /dev/null +++ b/src/core/server/saved_objects/routes/bulk_resolve.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; +import { InternalCoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; + +interface RouteDependencies { + coreUsageData: InternalCoreUsageDataSetup; +} + +export const registerBulkResolveRoute = (router: IRouter, { coreUsageData }: RouteDependencies) => { + router.post( + { + path: '/_bulk_resolve', + validate: { + body: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + }, + }, + catchAndReturnBoomErrors(async (context, req, res) => { + const usageStatsClient = coreUsageData.getClient(); + usageStatsClient.incrementSavedObjectsBulkResolve({ request: req }).catch(() => {}); + + const result = await context.core.savedObjects.client.bulkResolve(req.body); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 461f837480789d..d7cc8af07b0ab0 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -26,6 +26,7 @@ import { registerResolveImportErrorsRoute } from './resolve_import_errors'; import { registerMigrateRoute } from './migrate'; import { registerLegacyImportRoute } from './legacy_import_export/import'; import { registerLegacyExportRoute } from './legacy_import_export/export'; +import { registerBulkResolveRoute } from './bulk_resolve'; import { registerDeleteUnknownTypesRoute } from './deprecations'; import { KibanaConfigType } from '../../kibana_config'; @@ -56,6 +57,7 @@ export function registerRoutes({ registerUpdateRoute(router, { coreUsageData }); registerBulkGetRoute(router, { coreUsageData }); registerBulkCreateRoute(router, { coreUsageData }); + registerBulkResolveRoute(router, { coreUsageData }); registerBulkUpdateRoute(router, { coreUsageData }); registerExportRoute(router, { config, coreUsageData }); registerImportRoute(router, { config, coreUsageData }); diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_resolve.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_resolve.test.ts new file mode 100644 index 00000000000000..d8cdb709d59699 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_resolve.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { registerBulkResolveRoute } from '../bulk_resolve'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { CoreUsageStatsClient } from '../../../core_usage_data'; +import { coreUsageStatsClientMock } from '../../../core_usage_data/core_usage_stats_client.mock'; +import { coreUsageDataServiceMock } from '../../../core_usage_data/core_usage_data_service.mock'; +import { setupServer } from '../test_utils'; + +type SetupServerReturn = UnwrapPromise>; + +describe('POST /api/saved_objects/_bulk_resolve', () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let handlerContext: SetupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + let coreUsageStatsClient: jest.Mocked; + + beforeEach(async () => { + ({ server, httpSetup, handlerContext } = await setupServer()); + savedObjectsClient = handlerContext.savedObjects.client; + + savedObjectsClient.bulkResolve.mockResolvedValue({ + resolved_objects: [], + }); + const router = httpSetup.createRouter('/api/saved_objects/'); + coreUsageStatsClient = coreUsageStatsClientMock.create(); + coreUsageStatsClient.incrementSavedObjectsBulkResolve.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail + const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); + registerBulkResolveRoute(router, { coreUsageData }); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response and records usage stats', async () => { + const clientResponse = { + resolved_objects: [ + { + saved_object: { + id: 'abc123', + type: 'index-pattern', + title: 'logstash-*', + version: 'foo', + references: [], + attributes: {}, + }, + outcome: 'exactMatch' as const, + }, + ], + }; + savedObjectsClient.bulkResolve.mockImplementation(() => Promise.resolve(clientResponse)); + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_resolve') + .send([ + { + id: 'abc123', + type: 'index-pattern', + }, + ]) + .expect(200); + + expect(result.body).toEqual(clientResponse); + expect(coreUsageStatsClient.incrementSavedObjectsBulkResolve).toHaveBeenCalledWith({ + request: expect.anything(), + }); + }); + + it('calls upon savedObjectClient.bulkResolve', async () => { + const docs = [ + { + id: 'abc123', + type: 'index-pattern', + }, + ]; + + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_bulk_resolve') + .send(docs) + .expect(200); + + expect(savedObjectsClient.bulkResolve).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkResolve).toHaveBeenCalledWith(docs); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/integration_tests/repository_with_proxy.test.ts b/src/core/server/saved_objects/service/lib/integration_tests/repository_with_proxy.test.ts index 8428e7be91ae87..925b23a64f03e0 100644 --- a/src/core/server/saved_objects/service/lib/integration_tests/repository_with_proxy.test.ts +++ b/src/core/server/saved_objects/service/lib/integration_tests/repository_with_proxy.test.ts @@ -245,6 +245,15 @@ describe('404s from proxies', () => { expect(docsFound.saved_objects.length).toBeGreaterThan(0); }); + it('handles `bulkResolve` requests that are successful when the proxy passes through the product header', async () => { + const docsToGet = myOtherTypeDocs; + const docsFound = await repository.bulkResolve( + docsToGet.map((doc) => ({ id: doc.id, type: 'my_other_type' })) + ); + expect(docsFound.resolved_objects.length).toBeGreaterThan(0); + expect(docsFound.resolved_objects[0].outcome).toBe('exactMatch'); + }); + it('handles `resolve` requests that are successful with an exact match', async () => { const resolvedExactMatch = await repository.resolve('my_other_type', `${myOtherType.id}`); expect(resolvedExactMatch.outcome).toBe('exactMatch'); @@ -399,14 +408,27 @@ describe('404s from proxies', () => { expect(genericNotFoundEsUnavailableError(deleteErr, 'my_type', 'myTypeId1')); }); + it('returns an EsUnavailable error on `bulkResolve` requests with a 404 proxy response and wrong product header for an exact match', async () => { + const docsToGet = myTypeDocs; + let testBulkResolveErr: any; + setProxyInterrupt('internalBulkResolve'); + try { + await repository.bulkGet(docsToGet.map((doc) => ({ id: doc.id, type: 'my_type' }))); + } catch (err) { + testBulkResolveErr = err; + } + expect(genericNotFoundEsUnavailableError(testBulkResolveErr)); + }); + it('returns an EsUnavailable error on `resolve` requests with a 404 proxy response and wrong product header for an exact match', async () => { + setProxyInterrupt('internalBulkResolve'); let testResolveErr: any; try { await repository.resolve('my_type', 'myTypeId1'); } catch (err) { testResolveErr = err; } - expect(genericNotFoundEsUnavailableError(testResolveErr, 'my_type', 'myTypeId1')); + expect(genericNotFoundEsUnavailableError(testResolveErr)); }); it('returns an EsUnavailable error on `bulkGet` requests with a 404 proxy response and wrong product header', async () => { diff --git a/src/core/server/saved_objects/service/lib/integration_tests/repository_with_proxy_utils.ts b/src/core/server/saved_objects/service/lib/integration_tests/repository_with_proxy_utils.ts index cb0b2bd835bb9c..6f1c2c523226cc 100644 --- a/src/core/server/saved_objects/service/lib/integration_tests/repository_with_proxy_utils.ts +++ b/src/core/server/saved_objects/service/lib/integration_tests/repository_with_proxy_utils.ts @@ -27,6 +27,7 @@ export const setProxyInterrupt = ( | 'find' | 'openPit' | 'deleteByNamespace' + | 'internalBulkResolve' | null ) => (proxyInterrupt = testArg); @@ -118,7 +119,11 @@ export const declarePostMgetRoute = (hapiServer: Hapi.Server, hostname: string, parse: false, }, handler: (req, h) => { - if (proxyInterrupt === 'bulkGetMyType' || proxyInterrupt === 'checkConficts') { + if ( + proxyInterrupt === 'bulkGetMyType' || + proxyInterrupt === 'checkConficts' || + proxyInterrupt === 'internalBulkResolve' + ) { return proxyResponseHandler(h, hostname, port); } else { return relayHandler(h, hostname, port); diff --git a/src/core/server/saved_objects/service/lib/internal_bulk_resolve.test.mock.ts b/src/core/server/saved_objects/service/lib/internal_bulk_resolve.test.mock.ts new file mode 100644 index 00000000000000..fbd774f1c10d55 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/internal_bulk_resolve.test.mock.ts @@ -0,0 +1,37 @@ +/* + * 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 * as InternalUtils from './internal_utils'; +import type { isNotFoundFromUnsupportedServer } from '../../../elasticsearch'; + +export const mockGetSavedObjectFromSource = jest.fn() as jest.MockedFunction< + typeof InternalUtils['getSavedObjectFromSource'] +>; +export const mockRawDocExistsInNamespace = jest.fn() as jest.MockedFunction< + typeof InternalUtils['rawDocExistsInNamespace'] +>; + +jest.mock('./internal_utils', () => { + const actual = jest.requireActual('./internal_utils'); + return { + ...actual, + getSavedObjectFromSource: mockGetSavedObjectFromSource, + rawDocExistsInNamespace: mockRawDocExistsInNamespace, + }; +}); + +export const mockIsNotFoundFromUnsupportedServer = jest.fn() as jest.MockedFunction< + typeof isNotFoundFromUnsupportedServer +>; +jest.mock('../../../elasticsearch', () => { + const actual = jest.requireActual('../../../elasticsearch'); + return { + ...actual, + isNotFoundFromUnsupportedServer: mockIsNotFoundFromUnsupportedServer, + }; +}); diff --git a/src/core/server/saved_objects/service/lib/internal_bulk_resolve.test.ts b/src/core/server/saved_objects/service/lib/internal_bulk_resolve.test.ts new file mode 100644 index 00000000000000..046bc67f13dc64 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/internal_bulk_resolve.test.ts @@ -0,0 +1,315 @@ +/* + * 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 { + mockGetSavedObjectFromSource, + mockRawDocExistsInNamespace, + mockIsNotFoundFromUnsupportedServer, +} from './internal_bulk_resolve.test.mock'; + +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import type { ElasticsearchClient } from 'src/core/server/elasticsearch'; +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + +import { LEGACY_URL_ALIAS_TYPE } from '../../object_types'; +import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; +import { SavedObjectsSerializer } from '../../serialization'; +import { SavedObjectsErrorHelpers } from './errors'; +import { SavedObjectsBulkResolveObject } from '../saved_objects_client'; +import { SavedObject, SavedObjectsBaseOptions } from '../../types'; +import { internalBulkResolve, InternalBulkResolveParams } from './internal_bulk_resolve'; + +const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 }; +const OBJ_TYPE = 'obj-type'; +const UNSUPPORTED_TYPE = 'unsupported-type'; + +beforeEach(() => { + mockGetSavedObjectFromSource.mockReset(); + mockGetSavedObjectFromSource.mockImplementation( + (_registry, _type, id) => (`mock-obj-for-${id}` as unknown) as SavedObject + ); + mockRawDocExistsInNamespace.mockReset(); + mockRawDocExistsInNamespace.mockReturnValue(true); // return true by default + mockIsNotFoundFromUnsupportedServer.mockReset(); + mockIsNotFoundFromUnsupportedServer.mockReturnValue(false); +}); + +describe('internalBulkResolve', () => { + let client: DeeplyMockedKeys; + let serializer: SavedObjectsSerializer; + let incrementCounterInternal: jest.Mock; + + /** Sets up the type registry, saved objects client, etc. and return the full parameters object to be passed to `internalBulkResolve` */ + function setup( + objects: SavedObjectsBulkResolveObject[], + options: SavedObjectsBaseOptions = {} + ): InternalBulkResolveParams { + const registry = typeRegistryMock.create(); + client = elasticsearchClientMock.createElasticsearchClient(); + serializer = new SavedObjectsSerializer(registry); + incrementCounterInternal = jest.fn().mockRejectedValue(new Error('increment error')); // mock error to implicitly test that it is caught and swallowed + return { + registry: typeRegistryMock.create(), // doesn't need additional mocks for this test suite + allowedTypes: [OBJ_TYPE], + client, + serializer, + getIndexForType: (type: string) => `index-for-${type}`, + incrementCounterInternal, + objects, + options, + }; + } + + /** Mocks the elasticsearch client so it returns the expected results for a bulk operation */ + function mockBulkResults( + ...results: Array<{ found: boolean; targetId?: string; disabled?: boolean }> + ) { + client.bulk.mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + items: results.map(({ found, targetId, disabled }) => ({ + update: { + _index: 'doesnt-matter', + status: 0, + get: { + found, + _source: { + ...((targetId || disabled) && { + [LEGACY_URL_ALIAS_TYPE]: { targetId, disabled }, + }), + }, + ...VERSION_PROPS, + }, + }, + })), + errors: false, + took: 0, + }) + ); + } + + /** Mocks the elasticsearch client so it returns the expected results for an mget operation*/ + function mockMgetResults(...results: Array<{ found: boolean }>) { + client.mget.mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + docs: results.map((x) => { + return x.found + ? { + _id: 'doesnt-matter', + _index: 'doesnt-matter', + _source: { + foo: 'bar', + }, + ...VERSION_PROPS, + found: true, + } + : { + _id: 'doesnt-matter', + _index: 'doesnt-matter', + found: false, + }; + }), + }) + ); + } + + /** Asserts that bulk is called for the given aliases */ + function expectBulkArgs(namespace: string, aliasIds: string[]) { + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ + body: aliasIds + .map((id) => [ + { + update: { + _id: `legacy-url-alias:${namespace}:${OBJ_TYPE}:${id}`, + _index: `index-for-${LEGACY_URL_ALIAS_TYPE}`, + _source: true, + }, + }, + { script: expect.any(Object) }, + ]) + .flat(), + }) + ); + } + + /** Asserts that mget is called for the given objects */ + function expectMgetArgs(namespace: string | undefined, objectIds: string[]) { + expect(client.mget).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledWith( + { + body: { + docs: objectIds.map((id) => ({ + _id: serializer.generateRawId(namespace, OBJ_TYPE, id), + _index: `index-for-${OBJ_TYPE}`, + })), + }, + }, + expect.anything() + ); + } + + function expectUnsupportedTypeError(id: string) { + const error = SavedObjectsErrorHelpers.createUnsupportedTypeError(UNSUPPORTED_TYPE); + return { type: UNSUPPORTED_TYPE, id, error }; + } + function expectNotFoundError(id: string) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(OBJ_TYPE, id); + return { type: OBJ_TYPE, id, error }; + } + function expectExactMatchResult(id: string) { + return { saved_object: `mock-obj-for-${id}`, outcome: 'exactMatch' }; + } + function expectAliasMatchResult(id: string) { + return { saved_object: `mock-obj-for-${id}`, outcome: 'aliasMatch', alias_target_id: id }; + } + // eslint-disable-next-line @typescript-eslint/naming-convention + function expectConflictResult(id: string, alias_target_id: string) { + return { saved_object: `mock-obj-for-${id}`, outcome: 'conflict', alias_target_id }; + } + + it('throws if mget call results in non-ES-originated 404 error', async () => { + const objects = [{ type: OBJ_TYPE, id: '1' }]; + const params = setup(objects, { namespace: 'space-x' }); + mockBulkResults( + { found: false } // fetch alias for obj 1 + ); + mockMgetResults( + { found: false } // fetch obj 1 (actual result body doesn't matter, just needs statusCode and headers) + ); + mockIsNotFoundFromUnsupportedServer.mockReturnValue(true); + + await expect(() => internalBulkResolve(params)).rejects.toThrow( + SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError() + ); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); + }); + + it('returns an empty array if no object args are passed in', async () => { + const params = setup([], { namespace: 'space-x' }); + + const result = await internalBulkResolve(params); + expect(client.bulk).not.toHaveBeenCalled(); + expect(client.mget).not.toHaveBeenCalled(); + expect(result.resolved_objects).toEqual([]); + }); + + it('returns errors for unsupported object types', async () => { + const objects = [{ type: UNSUPPORTED_TYPE, id: '1' }]; + const params = setup(objects, { namespace: 'space-x' }); + + const result = await internalBulkResolve(params); + expect(client.bulk).not.toHaveBeenCalled(); + expect(client.mget).not.toHaveBeenCalled(); + expect(result.resolved_objects).toEqual([expectUnsupportedTypeError('1')]); + }); + + it('returns errors for objects that are not found', async () => { + const objects = [ + { type: OBJ_TYPE, id: '1' }, // does not have an alias, and is not found + { type: OBJ_TYPE, id: '2' }, // has an alias, but the object _and_ the alias target are not found + { type: OBJ_TYPE, id: '3' }, // has an alias, and the object and alias target are both found, but the object _and_ the alias target do not exist in this space + ]; + const params = setup(objects, { namespace: 'space-x' }); + mockBulkResults( + { found: false }, // fetch alias for obj 1 + { found: true, targetId: '2-newId' }, // fetch alias for obj 2 + { found: true, targetId: '3-newId' } // fetch alias for obj 3 + ); + mockMgetResults( + { found: false }, // fetch obj 1 + { found: false }, // fetch obj 2 + { found: false }, // fetch obj 2-newId + { found: true }, // fetch obj 3 + { found: true } // fetch obj 3-newId + ); + mockRawDocExistsInNamespace.mockReturnValue(false); // for objs 3 and 3-newId + + const result = await internalBulkResolve(params); + expectBulkArgs('space-x', ['1', '2', '3']); + expectMgetArgs('space-x', ['1', '2', '2-newId', '3', '3-newId']); + expect(mockRawDocExistsInNamespace).toHaveBeenCalledTimes(2); // for objs 3 and 3-newId + expect(result.resolved_objects).toEqual([ + expectNotFoundError('1'), + expectNotFoundError('2'), + expectNotFoundError('3'), + ]); + }); + + it('does not call bulk update in the Default space', async () => { + // Aliases cannot exist in the Default space, so we skip the alias check part of the alogrithm in that case (e.g., bulk update) + for (const namespace of [undefined, 'default']) { + const params = setup([{ type: OBJ_TYPE, id: '1' }], { namespace }); + mockMgetResults( + { found: true } // fetch obj 1 + ); + + await internalBulkResolve(params); + expect(client.bulk).not.toHaveBeenCalled(); + // 'default' is normalized to undefined + expectMgetArgs(undefined, ['1']); + } + }); + + it('ignores aliases that are disabled', async () => { + const objects = [{ type: OBJ_TYPE, id: '1' }]; + const params = setup(objects, { namespace: 'space-x' }); + mockBulkResults( + { found: true, targetId: '1-newId', disabled: true } // fetch alias for obj 1 + ); + mockMgetResults( + { found: true } // fetch obj 1 + // does not attempt to fetch obj 1-newId, because that alias is disabled + ); + + const result = await internalBulkResolve(params); + expectBulkArgs('space-x', ['1']); + expectMgetArgs('space-x', ['1']); + expect(result.resolved_objects).toEqual([ + expectExactMatchResult('1'), // result for obj 1 + ]); + }); + + it('returns a mix of results and increments the usage stats counter correctly', async () => { + const objects = [ + { type: UNSUPPORTED_TYPE, id: '1' }, // unsupported type error + { type: OBJ_TYPE, id: '2' }, // not found error + { type: OBJ_TYPE, id: '3' }, // exactMatch outcome + { type: OBJ_TYPE, id: '4' }, // aliasMatch outcome + { type: OBJ_TYPE, id: '5' }, // conflict outcome + ]; + const params = setup(objects, { namespace: 'space-x' }); + mockBulkResults( + // does not attempt to fetch alias for obj 1, because that is an unsupported type + { found: false }, // fetch alias for obj 2 + { found: false }, // fetch alias for obj 3 + { found: true, targetId: '4-newId' }, // fetch alias for obj 4 + { found: true, targetId: '5-newId' } // fetch alias for obj 5 + ); + mockMgetResults( + { found: false }, // fetch obj 2 + { found: true }, // fetch obj 3 + { found: false }, // fetch obj 4 + { found: true }, // fetch obj 4-newId + { found: true }, // fetch obj 5 + { found: true } // fetch obj 5-newId + ); + + const result = await internalBulkResolve(params); + expectBulkArgs('space-x', ['2', '3', '4', '5']); + expectMgetArgs('space-x', ['2', '3', '4', '4-newId', '5', '5-newId']); + expect(result.resolved_objects).toEqual([ + expectUnsupportedTypeError('1'), + expectNotFoundError('2'), + expectExactMatchResult('3'), + expectAliasMatchResult('4-newId'), + expectConflictResult('5', '5-newId'), + ]); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/internal_bulk_resolve.ts b/src/core/server/saved_objects/service/lib/internal_bulk_resolve.ts new file mode 100644 index 00000000000000..f53a85a9a03ef6 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/internal_bulk_resolve.ts @@ -0,0 +1,322 @@ +/* + * 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 { MgetHit } from '@elastic/elasticsearch/api/types'; + +import { + CORE_USAGE_STATS_ID, + CORE_USAGE_STATS_TYPE, + REPOSITORY_RESOLVE_OUTCOME_STATS, +} from '../../../core_usage_data'; +import { isNotFoundFromUnsupportedServer } from '../../../elasticsearch'; +import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; +import type { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { SavedObjectsRawDocSource, SavedObjectsSerializer } from '../../serialization'; +import type { SavedObject, SavedObjectsBaseOptions } from '../../types'; +import type { + SavedObjectsBulkResolveObject, + SavedObjectsResolveResponse, +} from '../saved_objects_client'; +import { DecoratedError, SavedObjectsErrorHelpers } from './errors'; +import { + getCurrentTime, + getSavedObjectFromSource, + normalizeNamespace, + rawDocExistsInNamespace, + Either, + Right, + isLeft, + isRight, +} from './internal_utils'; +import { + SavedObjectsIncrementCounterField, + SavedObjectsIncrementCounterOptions, +} from './repository'; +import type { RepositoryEsClient } from './repository_es_client'; + +/** + * Parameters for the internal bulkResolve function. + * + * @internal + */ +export interface InternalBulkResolveParams { + registry: ISavedObjectTypeRegistry; + allowedTypes: string[]; + client: RepositoryEsClient; + serializer: SavedObjectsSerializer; + getIndexForType: (type: string) => string; + incrementCounterInternal: ( + type: string, + id: string, + counterFields: Array, + options?: SavedObjectsIncrementCounterOptions + ) => Promise>; + objects: SavedObjectsBulkResolveObject[]; + options?: SavedObjectsBaseOptions; +} + +/** + * The response when objects are resolved. + * + * @public + */ +export interface InternalSavedObjectsBulkResolveResponse { + resolved_objects: Array | InternalBulkResolveError>; +} + +/** + * Error result for the internal bulkResolve function. + * + * @internal + */ +export interface InternalBulkResolveError { + type: string; + id: string; + error: DecoratedError; +} + +export async function internalBulkResolve( + params: InternalBulkResolveParams +): Promise> { + const { + registry, + allowedTypes, + client, + serializer, + getIndexForType, + incrementCounterInternal, + objects, + options = {}, + } = params; + + if (objects.length === 0) { + return { resolved_objects: [] }; + } + + const allObjects = validateObjectTypes(objects, allowedTypes); + const validObjects = allObjects.filter(isRight); + + const namespace = normalizeNamespace(options.namespace); + const requiresAliasCheck = namespace !== undefined; + + const aliasDocs = requiresAliasCheck + ? await fetchAndUpdateAliases(validObjects, client, serializer, getIndexForType, namespace) + : []; + + const docsToBulkGet: Array<{ _id: string; _index: string }> = []; + const aliasTargetIds: Array = []; + validObjects.forEach(({ value: { type, id } }, i) => { + const objectIndex = getIndexForType(type); + docsToBulkGet.push({ + // attempt to find an exact match for the given ID + _id: serializer.generateRawId(namespace, type, id), + _index: objectIndex, + }); + if (requiresAliasCheck) { + const aliasDoc = aliasDocs[i]; + if (aliasDoc?.found) { + const legacyUrlAlias: LegacyUrlAlias = aliasDoc._source[LEGACY_URL_ALIAS_TYPE]; + if (!legacyUrlAlias.disabled) { + docsToBulkGet.push({ + // also attempt to find a match for the legacy URL alias target ID + _id: serializer.generateRawId(namespace, type, legacyUrlAlias.targetId), + _index: objectIndex, + }); + aliasTargetIds.push(legacyUrlAlias.targetId); + return; + } + } + } + aliasTargetIds.push(undefined); + }); + + const bulkGetResponse = docsToBulkGet.length + ? await client.mget( + { body: { docs: docsToBulkGet } }, + { ignore: [404] } + ) + : undefined; + // exit early if a 404 isn't from elasticsearch + if ( + bulkGetResponse && + isNotFoundFromUnsupportedServer({ + statusCode: bulkGetResponse.statusCode, + headers: bulkGetResponse.headers, + }) + ) { + throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); + } + + let getResponseIndex = 0; + let aliasTargetIndex = 0; + const resolveCounter = new ResolveCounter(); + const resolvedObjects = allObjects.map | InternalBulkResolveError>( + (either) => { + if (isLeft(either)) { + return either.value; + } + const exactMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++]; + let aliasMatchDoc: MgetHit | undefined; + const aliasTargetId = aliasTargetIds[aliasTargetIndex++]; + if (aliasTargetId !== undefined) { + aliasMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++]; + } + const foundExactMatch = + // @ts-expect-error MultiGetHit._source is optional + exactMatchDoc.found && rawDocExistsInNamespace(registry, exactMatchDoc, namespace); + const foundAliasMatch = + // @ts-expect-error MultiGetHit._source is optional + aliasMatchDoc?.found && rawDocExistsInNamespace(registry, aliasMatchDoc, namespace); + + const { type, id } = either.value; + let result: SavedObjectsResolveResponse | null = null; + if (foundExactMatch && foundAliasMatch) { + result = { + // @ts-expect-error MultiGetHit._source is optional + saved_object: getSavedObjectFromSource(registry, type, id, exactMatchDoc), + outcome: 'conflict', + alias_target_id: aliasTargetId!, + }; + resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT); + } else if (foundExactMatch) { + result = { + // @ts-expect-error MultiGetHit._source is optional + saved_object: getSavedObjectFromSource(registry, type, id, exactMatchDoc), + outcome: 'exactMatch', + }; + resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); + } else if (foundAliasMatch) { + result = { + // @ts-expect-error MultiGetHit._source is optional + saved_object: getSavedObjectFromSource(registry, type, aliasTargetId!, aliasMatchDoc), + outcome: 'aliasMatch', + alias_target_id: aliasTargetId, + }; + resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH); + } + + if (result !== null) { + return result; + } + resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); + return { + type, + id, + error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id), + }; + } + ); + + await incrementCounterInternal( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + resolveCounter.getCounterFields(), + { refresh: false } + ).catch(() => {}); // if the call fails for some reason, intentionally swallow the error + + return { resolved_objects: resolvedObjects }; +} + +/** Separates valid and invalid object types */ +function validateObjectTypes(objects: SavedObjectsBulkResolveObject[], allowedTypes: string[]) { + return objects.map>((object) => { + const { type, id } = object; + if (!allowedTypes.includes(type)) { + return { + tag: 'Left', + value: { + type, + id, + error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type), + }, + }; + } + return { + tag: 'Right', + value: object, + }; + }); +} + +async function fetchAndUpdateAliases( + validObjects: Array>, + client: RepositoryEsClient, + serializer: SavedObjectsSerializer, + getIndexForType: (type: string) => string, + namespace: string | undefined +) { + if (validObjects.length === 0) { + return []; + } + + const time = getCurrentTime(); + const bulkUpdateDocs = validObjects + .map(({ value: { type, id } }) => [ + { + update: { + _id: serializer.generateRawLegacyUrlAliasId(namespace!, type, id), + _index: getIndexForType(LEGACY_URL_ALIAS_TYPE), + _source: true, + }, + }, + { + script: { + source: ` + if (ctx._source[params.type].disabled != true) { + if (ctx._source[params.type].resolveCounter == null) { + ctx._source[params.type].resolveCounter = 1; + } + else { + ctx._source[params.type].resolveCounter += 1; + } + ctx._source[params.type].lastResolved = params.time; + ctx._source.updated_at = params.time; + } + `, + lang: 'painless', + params: { + type: LEGACY_URL_ALIAS_TYPE, + time, + }, + }, + }, + ]) + .flat(); + + const bulkUpdateResponse = await client.bulk({ + refresh: false, + require_alias: true, + body: bulkUpdateDocs, + }); + return bulkUpdateResponse.body.items.map((item) => { + // Map the bulk update response to the `_source` fields that were returned for each document + return item.update?.get; + }); +} + +class ResolveCounter { + private record = new Map(); + + public recordOutcome(outcome: string) { + const val = this.record.get(outcome) ?? 0; + this.record.set(outcome, val + 1); + } + + public getCounterFields() { + const counterFields: SavedObjectsIncrementCounterField[] = []; + let total = 0; + for (const [fieldName, incrementBy] of this.record.entries()) { + total += incrementBy; + counterFields.push({ fieldName, incrementBy }); + } + if (total > 0) { + counterFields.push({ fieldName: REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL, incrementBy: total }); + } + return counterFields; + } +} diff --git a/src/core/server/saved_objects/service/lib/internal_utils.test.ts b/src/core/server/saved_objects/service/lib/internal_utils.test.ts index a0b8581e582f6f..1a94e22d61f868 100644 --- a/src/core/server/saved_objects/service/lib/internal_utils.test.ts +++ b/src/core/server/saved_objects/service/lib/internal_utils.test.ts @@ -11,7 +11,9 @@ import type { SavedObjectsRawDoc } from '../../serialization'; import { encodeHitVersion } from '../../version'; import { getBulkOperationError, + getCurrentTime, getSavedObjectFromSource, + normalizeNamespace, rawDocExistsInNamespace, rawDocExistsInNamespaces, } from './internal_utils'; @@ -326,3 +328,34 @@ describe('#rawDocExistsInNamespaces', () => { }); }); }); + +describe('#normalizeNamespace', () => { + it('throws an error for * (All namespaces string)', () => { + expect(() => normalizeNamespace(ALL_NAMESPACES_STRING)).toThrowErrorMatchingInlineSnapshot( + `"\\"options.namespace\\" cannot be \\"*\\": Bad Request"` + ); + }); + + it('returns undefined for undefined or "default" namespace inputs', () => { + [undefined, 'default'].forEach((namespace) => { + expect(normalizeNamespace(namespace)).toBeUndefined(); + }); + }); + + it('returns namespace string for other namespace string inputs', () => { + ['foo', 'bar'].forEach((namespace) => { + expect(normalizeNamespace(namespace)).toBe(namespace); + }); + }); +}); + +describe('#getCurrentTime', () => { + let dateNowSpy: jest.SpyInstance; + + beforeAll(() => (dateNowSpy = jest.spyOn(Date, 'now').mockImplementation(() => 1631307600000))); + afterAll(() => dateNowSpy.mockRestore()); + + it('returns the current time', () => { + expect(getCurrentTime()).toEqual('2021-09-10T21:00:00.000Z'); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/internal_utils.ts b/src/core/server/saved_objects/service/lib/internal_utils.ts index ed6ede0fe6d491..b480000f1b3daf 100644 --- a/src/core/server/saved_objects/service/lib/internal_utils.ts +++ b/src/core/server/saved_objects/service/lib/internal_utils.ts @@ -14,6 +14,38 @@ import { decodeRequestVersion, encodeHitVersion } from '../../version'; import { SavedObjectsErrorHelpers } from './errors'; import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from './utils'; +/** + * Discriminated union (TypeScript approximation of an algebraic data type); this design pattern used for internal repository operations. + * @internal + */ +export type Either = Left | Right; +/** + * Left part of discriminated union ({@link Either}). + * @internal + */ +export interface Left { + tag: 'Left'; + value: L; +} +/** + * Right part of discriminated union ({@link Either}). + * @internal + */ +export interface Right { + tag: 'Right'; + value: R; +} +/** + * Type guard for left part of discriminated union ({@link Left}, {@link Either}). + * @internal + */ +export const isLeft = (either: Either): either is Left => either.tag === 'Left'; +/** + * Type guard for right part of discriminated union ({@link Right}, {@link Either}). + * @internal + */ +export const isRight = (either: Either): either is Right => either.tag === 'Right'; + /** * Checks the raw response of a bulk operation and returns an error if necessary. * @@ -121,6 +153,8 @@ export function getSavedObjectFromSource( * @param registry * @param raw * @param namespace + * + * @internal */ export function rawDocExistsInNamespace( registry: ISavedObjectTypeRegistry, @@ -153,6 +187,8 @@ export function rawDocExistsInNamespace( * @param registry * @param raw * @param namespaces + * + * @internal */ export function rawDocExistsInNamespaces( registry: ISavedObjectTypeRegistry, @@ -179,3 +215,30 @@ export function rawDocExistsInNamespaces( return existingNamespaces.some((x) => x === ALL_NAMESPACES_STRING || namespacesToCheck.has(x)); } + +/** + * Ensure that a namespace is always in its namespace ID representation. + * This allows `'default'` to be used interchangeably with `undefined`. + * + * @param namespace + * + * @internal + */ +export function normalizeNamespace(namespace?: string) { + if (namespace === ALL_NAMESPACES_STRING) { + throw SavedObjectsErrorHelpers.createBadRequestError('"options.namespace" cannot be "*"'); + } else if (namespace === undefined) { + return namespace; + } else { + return SavedObjectsUtils.namespaceStringToId(namespace); + } +} + +/** + * Returns the current time. For use in Elasticsearch operations. + * + * @internal + */ +export function getCurrentTime() { + return new Date(Date.now()).toISOString(); +} diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index 0e1426a58f8ae1..9c9b71d42e45ea 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -22,6 +22,7 @@ const create = () => { closePointInTime: jest.fn(), createPointInTimeFinder: jest.fn(), openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), + bulkResolve: jest.fn(), resolve: jest.fn(), update: jest.fn(), deleteByNamespace: jest.fn(), diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 0d7365c4b97c16..72ca2d15007b88 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -10,10 +10,11 @@ import { pointInTimeFinderMock, mockCollectMultiNamespaceReferences, mockGetBulkOperationError, + mockInternalBulkResolve, mockUpdateObjectsSpaces, + mockGetCurrentTime, } from './repository.test.mock'; -import { CORE_USAGE_STATS_TYPE, REPOSITORY_RESOLVE_OUTCOME_STATS } from '../../../core_usage_data'; import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; import { SavedObjectsErrorHelpers } from './errors'; @@ -23,7 +24,6 @@ import { loggerMock } from '../../../logging/logger.mock'; import { SavedObjectsSerializer } from '../../serialization'; import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; -import { LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { DocumentMigrator } from '../../migrations/core/document_migrator'; import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; @@ -33,6 +33,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch'; const { nodeTypes } = esKuery; jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); + // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -298,7 +299,7 @@ describe('SavedObjectsRepository', () => { logger, }); - savedObjectsRepository._getCurrentTime = jest.fn(() => mockTimestamp); + mockGetCurrentTime.mockReturnValue(mockTimestamp); getSearchDslNS.getSearchDsl.mockClear(); }); @@ -1282,6 +1283,59 @@ describe('SavedObjectsRepository', () => { }); }); + describe('#bulkResolve', () => { + afterEach(() => { + mockInternalBulkResolve.mockReset(); + }); + + it('passes arguments to the internalBulkResolve module and returns the expected results', async () => { + mockInternalBulkResolve.mockResolvedValue({ + resolved_objects: [ + { saved_object: 'mock-object', outcome: 'exactMatch' }, + { + type: 'obj-type', + id: 'obj-id-2', + error: SavedObjectsErrorHelpers.createGenericNotFoundError('obj-type', 'obj-id-2'), + }, + ], + }); + + const objects = [ + { type: 'obj-type', id: 'obj-id-1' }, + { type: 'obj-type', id: 'obj-id-2' }, + ]; + await expect(savedObjectsRepository.bulkResolve(objects)).resolves.toEqual({ + resolved_objects: [ + { + saved_object: 'mock-object', + outcome: 'exactMatch', + }, + { + saved_object: { + type: 'obj-type', + id: 'obj-id-2', + error: { + error: 'Not Found', + message: 'Saved object [obj-type/obj-id-2] not found', + statusCode: 404, + }, + }, + outcome: 'exactMatch', + }, + ], + }); + expect(mockInternalBulkResolve).toHaveBeenCalledTimes(1); + expect(mockInternalBulkResolve).toHaveBeenCalledWith(expect.objectContaining({ objects })); + }); + + it('throws when internalBulkResolve throws', async () => { + const error = new Error('Oh no!'); + mockInternalBulkResolve.mockRejectedValue(error); + + await expect(savedObjectsRepository.resolve()).rejects.toEqual(error); + }); + }); + describe('#bulkUpdate', () => { const obj1 = { type: 'config', @@ -3582,266 +3636,36 @@ describe('SavedObjectsRepository', () => { }); describe('#resolve', () => { - const type = 'index-pattern'; - const id = 'logstash-*'; - const aliasTargetId = 'some-other-id'; // only used for 'aliasMatch' and 'conflict' outcomes - const namespace = 'foo-namespace'; - - const getMockAliasDocument = (resolveCounter) => ({ - body: { - get: { - _source: { - [LEGACY_URL_ALIAS_TYPE]: { - targetId: aliasTargetId, - ...(resolveCounter && { resolveCounter }), - // other fields are not used by the repository - }, - }, - }, - }, + afterEach(() => { + mockInternalBulkResolve.mockReset(); }); - /** Each time resolve is called, usage stats are incremented depending upon the outcome. */ - const expectIncrementCounter = (n, outcomeStatString) => { - expect(client.update).toHaveBeenNthCalledWith( - n, - expect.objectContaining({ - body: expect.objectContaining({ - upsert: expect.objectContaining({ - [CORE_USAGE_STATS_TYPE]: { - [outcomeStatString]: 1, - [REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL]: 1, - }, - }), - }), - }), - expect.anything() - ); - }; - - describe('outcomes', () => { - describe('error', () => { - const expectNotFoundError = async (type, id, options) => { - await expect(savedObjectsRepository.resolve(type, id, options)).rejects.toThrowError( - createGenericNotFoundError(type, id) - ); - }; - - it('because type is invalid', async () => { - await expectNotFoundError('unknownType', id); - expect(client.update).not.toHaveBeenCalled(); - expect(client.get).not.toHaveBeenCalled(); - expect(client.mget).not.toHaveBeenCalled(); - }); - - it('because type is hidden', async () => { - await expectNotFoundError(HIDDEN_TYPE, id); - expect(client.update).not.toHaveBeenCalled(); - expect(client.get).not.toHaveBeenCalled(); - expect(client.mget).not.toHaveBeenCalled(); - }); - - it('because alias is not used and actual object is not found', async () => { - const options = { namespace: undefined }; - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise( - { found: false }, - undefined - ) // for actual target - ); - - await expectNotFoundError(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // incremented stats - expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target - expect(client.mget).not.toHaveBeenCalled(); - expectIncrementCounter(1, REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); - }); - - it('because actual object and alias object are both not found', async () => { - const options = { namespace }; - const objectResults = [ - { type, id, found: false }, - { type, id: aliasTargetId, found: false }, - ]; - client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object - const response = getMockMgetResponse(objectResults, options.namespace); - client.mget.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target - ); - - await expectNotFoundError(type, id, options); - expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats - expect(client.get).not.toHaveBeenCalled(); - expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target - expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); - }); - }); + it('passes arguments to the internalBulkResolve module and returns the result', async () => { + const expectedResult = { saved_object: 'mock-object', outcome: 'exactMatch' }; + mockInternalBulkResolve.mockResolvedValue({ resolved_objects: [expectedResult] }); - describe('exactMatch', () => { - it('because namespace is undefined', async () => { - const options = { namespace: undefined }; - const response = getMockGetResponse({ type, id }); - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target - ); - - const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // incremented stats - expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target - expect(client.mget).not.toHaveBeenCalled(); - expectIncrementCounter(1, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); - expect(result).toEqual({ - saved_object: expect.objectContaining({ type, id }), - outcome: 'exactMatch', - }); - }); - - describe('because alias is not used', () => { - const expectExactMatchResult = async (aliasResult) => { - const options = { namespace }; - if (!aliasResult.body) { - client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { ...aliasResult }) - ); - } else { - client.update.mockResolvedValueOnce(aliasResult); // for alias object - } - const response = getMockGetResponse({ type, id }, options.namespace); - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ ...response }) // for actual target - ); - - const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats - expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target - expect(client.mget).not.toHaveBeenCalled(); - expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); - expect(result).toEqual({ - saved_object: expect.objectContaining({ type, id }), - outcome: 'exactMatch', - }); - }; - - it('since alias call resulted in 404', async () => { - await expectExactMatchResult({ statusCode: 404 }); - }); - - it('since alias is not found', async () => { - await expectExactMatchResult({ body: { get: { found: false } } }); - }); - - it('since alias is disabled', async () => { - await expectExactMatchResult({ - body: { get: { _source: { [LEGACY_URL_ALIAS_TYPE]: { disabled: true } } } }, - }); - }); - }); - - describe('because alias is used', () => { - const expectExactMatchResult = async (objectResults) => { - const options = { namespace }; - client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object - const response = getMockMgetResponse(objectResults, options.namespace); - client.mget.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target - ); - - const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats - expect(client.get).not.toHaveBeenCalled(); - expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target - expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); - expect(result).toEqual({ - saved_object: expect.objectContaining({ type, id }), - outcome: 'exactMatch', - }); - }; - - it('but alias target is not found', async () => { - const objects = [ - { type, id }, - { type, id: aliasTargetId, found: false }, - ]; - await expectExactMatchResult(objects); - }); - - it('but alias target does not exist in this namespace', async () => { - const objects = [ - { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, // correct namespace field is added by getMockMgetResponse - { - type: MULTI_NAMESPACE_ISOLATED_TYPE, - id: aliasTargetId, - namespace: `not-${namespace}`, - }, // overrides namespace field that would otherwise be added by getMockMgetResponse - ]; - await expectExactMatchResult(objects); - }); - }); - }); - - describe('aliasMatch', () => { - const expectAliasMatchResult = async (objectResults) => { - const options = { namespace }; - client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object - const response = getMockMgetResponse(objectResults, options.namespace); - client.mget.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target - ); - - const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats - expect(client.get).not.toHaveBeenCalled(); - expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target - expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH); - expect(result).toEqual({ - saved_object: expect.objectContaining({ type, id: aliasTargetId }), - outcome: 'aliasMatch', - alias_target_id: aliasTargetId, - }); - }; + await expect(savedObjectsRepository.resolve('obj-type', 'obj-id')).resolves.toEqual( + expectedResult + ); + expect(mockInternalBulkResolve).toHaveBeenCalledTimes(1); + expect(mockInternalBulkResolve).toHaveBeenCalledWith( + expect.objectContaining({ objects: [{ type: 'obj-type', id: 'obj-id' }] }) + ); + }); - it('because actual target is not found', async () => { - const objects = [ - { type, id, found: false }, - { type, id: aliasTargetId }, - ]; - await expectAliasMatchResult(objects); - }); + it('throws when internalBulkResolve result is an error', async () => { + const error = new Error('Oh no!'); + const expectedResult = { type: 'obj-type', id: 'obj-id', error }; + mockInternalBulkResolve.mockResolvedValue({ resolved_objects: [expectedResult] }); - it('because actual target does not exist in this namespace', async () => { - const objects = [ - { type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse - { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse - ]; - await expectAliasMatchResult(objects); - }); - }); + await expect(savedObjectsRepository.resolve()).rejects.toEqual(error); + }); - describe('conflict', () => { - it('because actual target and alias target are both found', async () => { - const options = { namespace }; - const objectResults = [ - { type, id }, // correct namespace field is added by getMockMgetResponse - { type, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse - ]; - client.update.mockResolvedValueOnce(getMockAliasDocument()); // for alias object - const response = getMockMgetResponse(objectResults, options.namespace); - client.mget.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target - ); + it('throws when internalBulkResolve throws', async () => { + const error = new Error('Oh no!'); + mockInternalBulkResolve.mockRejectedValue(error); - const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats - expect(client.get).not.toHaveBeenCalled(); - expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target - expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT); - expect(result).toEqual({ - saved_object: expect.objectContaining({ type, id }), - outcome: 'conflict', - alias_target_id: aliasTargetId, - }); - }); - }); + await expect(savedObjectsRepository.resolve()).rejects.toEqual(error); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.test.mock.ts b/src/core/server/saved_objects/service/lib/repository.test.mock.ts index f044fe9279fbfe..d9a611226f8b53 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.test.mock.ts @@ -7,6 +7,7 @@ */ import type { collectMultiNamespaceReferences } from './collect_multi_namespace_references'; +import type { internalBulkResolve } from './internal_bulk_resolve'; import type * as InternalUtils from './internal_utils'; import type { updateObjectsSpaces } from './update_objects_spaces'; @@ -18,15 +19,25 @@ jest.mock('./collect_multi_namespace_references', () => ({ collectMultiNamespaceReferences: mockCollectMultiNamespaceReferences, })); +export const mockInternalBulkResolve = jest.fn() as jest.MockedFunction; + +jest.mock('./internal_bulk_resolve', () => ({ + internalBulkResolve: mockInternalBulkResolve, +})); + export const mockGetBulkOperationError = jest.fn() as jest.MockedFunction< typeof InternalUtils['getBulkOperationError'] >; +export const mockGetCurrentTime = jest.fn() as jest.MockedFunction< + typeof InternalUtils['getCurrentTime'] +>; jest.mock('./internal_utils', () => { const actual = jest.requireActual('./internal_utils'); return { ...actual, getBulkOperationError: mockGetBulkOperationError, + getCurrentTime: mockGetCurrentTime, }; }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index c425f8c40fed11..acbc4a6ee66d49 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -8,11 +8,6 @@ import { omit, isObject } from 'lodash'; import type { estypes } from '@elastic/elasticsearch'; -import { - CORE_USAGE_STATS_TYPE, - CORE_USAGE_STATS_ID, - REPOSITORY_RESOLVE_OUTCOME_STATS, -} from '../../../core_usage_data'; import type { ElasticsearchClient } from '../../../elasticsearch/'; import { isSupportedEsServer, isNotFoundFromUnsupportedServer } from '../../../elasticsearch'; import type { Logger } from '../../../logging'; @@ -57,6 +52,8 @@ import { SavedObjectsRemoveReferencesToOptions, SavedObjectsRemoveReferencesToResponse, SavedObjectsResolveResponse, + SavedObjectsBulkResolveObject, + SavedObjectsBulkResolveResponse, } from '../saved_objects_client'; import { SavedObject, @@ -65,16 +62,21 @@ import { SavedObjectsMigrationVersion, MutatingOperationRefreshSetting, } from '../../types'; -import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import { internalBulkResolve, InternalBulkResolveError } from './internal_bulk_resolve'; import { validateConvertFilterToKueryNode } from './filter_utils'; import { validateAndConvertAggregations } from './aggregations'; import { getBulkOperationError, + getCurrentTime, getExpectedVersionProperties, getSavedObjectFromSource, + normalizeNamespace, rawDocExistsInNamespace, rawDocExistsInNamespaces, + Either, + isLeft, + isRight, } from './internal_utils'; import { ALL_NAMESPACES_STRING, @@ -97,20 +99,6 @@ import { getIndexForType } from './get_index_for_type'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. -interface Left { - tag: 'Left'; - error: Record; -} - -interface Right { - tag: 'Right'; - value: Record; -} - -type Either = Left | Right; -const isLeft = (either: Either): either is Left => either.tag === 'Left'; -const isRight = (either: Either): either is Right => either.tag === 'Right'; - export interface SavedObjectsRepositoryOptions { index: string; mappings: IndexMapping; @@ -297,7 +285,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } - const time = this._getCurrentTime(); + const time = getCurrentTime(); let savedObjectNamespace: string | undefined; let savedObjectNamespaces: string[] | undefined; @@ -373,45 +361,47 @@ export class SavedObjectsRepository { ): Promise> { const { overwrite = false, refresh = DEFAULT_REFRESH_SETTING } = options; const namespace = normalizeNamespace(options.namespace); - const time = this._getCurrentTime(); + const time = getCurrentTime(); let bulkGetRequestIndexCounter = 0; - const expectedResults: Either[] = objects.map((object) => { - const { type, id, initialNamespaces } = object; - let error: DecoratedError | undefined; - if (!this._allowedTypes.includes(type)) { - error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); - } else { - try { - this.validateInitialNamespaces(type, initialNamespaces); - } catch (e) { - error = e; + const expectedResults: Array, Record>> = objects.map( + (object) => { + const { type, id, initialNamespaces } = object; + let error: DecoratedError | undefined; + if (!this._allowedTypes.includes(type)) { + error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } else { + try { + this.validateInitialNamespaces(type, initialNamespaces); + } catch (e) { + error = e; + } } - } - if (error) { - return { - tag: 'Left' as 'Left', - error: { id, type, error: errorContent(error) }, - }; - } + if (error) { + return { + tag: 'Left', + value: { id, type, error: errorContent(error) }, + }; + } - const method = id && overwrite ? 'index' : 'create'; - const requiresNamespacesCheck = id && this._registry.isMultiNamespace(type); + const method = id && overwrite ? 'index' : 'create'; + const requiresNamespacesCheck = id && this._registry.isMultiNamespace(type); - if (id == null) { - object.id = SavedObjectsUtils.generateId(); - } + if (id == null) { + object.id = SavedObjectsUtils.generateId(); + } - return { - tag: 'Right' as 'Right', - value: { - method, - object, - ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), - }, - }; - }); + return { + tag: 'Right', + value: { + method, + object, + ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + }, + }; + } + ); const bulkGetDocs = expectedResults .filter(isRight) @@ -443,7 +433,9 @@ export class SavedObjectsRepository { } let bulkRequestIndexCounter = 0; const bulkCreateParams: object[] = []; - const expectedBulkResults: Either[] = expectedResults.map((expectedBulkGetResult) => { + const expectedBulkResults: Array< + Either, Record> + > = expectedResults.map((expectedBulkGetResult) => { if (isLeft(expectedBulkGetResult)) { return expectedBulkGetResult; } @@ -470,8 +462,8 @@ export class SavedObjectsRepository { ) { const { id, type } = object; return { - tag: 'Left' as 'Left', - error: { + tag: 'Left', + value: { id, type, error: { @@ -527,7 +519,7 @@ export class SavedObjectsRepository { expectedResult.rawMigratedDoc._source ); - return { tag: 'Right' as 'Right', value: expectedResult }; + return { tag: 'Right', value: expectedResult }; }); const bulkResponse = bulkCreateParams.length @@ -541,7 +533,7 @@ export class SavedObjectsRepository { return { saved_objects: expectedBulkResults.map((expectedResult) => { if (isLeft(expectedResult)) { - return expectedResult.error as any; + return expectedResult.value as any; } const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value; @@ -578,13 +570,15 @@ export class SavedObjectsRepository { const namespace = normalizeNamespace(options.namespace); let bulkGetRequestIndexCounter = 0; - const expectedBulkGetResults: Either[] = objects.map((object) => { + const expectedBulkGetResults: Array< + Either, Record> + > = objects.map((object) => { const { type, id } = object; if (!this._allowedTypes.includes(type)) { return { - tag: 'Left' as 'Left', - error: { + tag: 'Left', + value: { id, type, error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)), @@ -593,7 +587,7 @@ export class SavedObjectsRepository { } return { - tag: 'Right' as 'Right', + tag: 'Right', value: { type, id, @@ -630,7 +624,7 @@ export class SavedObjectsRepository { const errors: SavedObjectsCheckConflictsResponse['errors'] = []; expectedBulkGetResults.forEach((expectedResult) => { if (isLeft(expectedResult)) { - errors.push(expectedResult.error as any); + errors.push(expectedResult.value as any); return; } @@ -979,7 +973,9 @@ export class SavedObjectsRepository { } let bulkGetRequestIndexCounter = 0; - const expectedBulkGetResults: Either[] = objects.map((object) => { + const expectedBulkGetResults: Array< + Either, Record> + > = objects.map((object) => { const { type, id, fields, namespaces } = object; let error: DecoratedError | undefined; @@ -995,13 +991,13 @@ export class SavedObjectsRepository { if (error) { return { - tag: 'Left' as 'Left', - error: { id, type, error: errorContent(error) }, + tag: 'Left', + value: { id, type, error: errorContent(error) }, }; } return { - tag: 'Right' as 'Right', + tag: 'Right', value: { type, id, @@ -1044,7 +1040,7 @@ export class SavedObjectsRepository { return { saved_objects: expectedBulkGetResults.map((expectedResult) => { if (isLeft(expectedResult)) { - return expectedResult.error as any; + return expectedResult.value as any; } const { @@ -1070,6 +1066,49 @@ export class SavedObjectsRepository { }; } + /** + * Resolves an array of objects by id, using any legacy URL aliases if they exist + * + * @param {array} objects - an array of objects containing id, type + * @param {object} [options={}] + * @property {string} [options.namespace] + * @returns {promise} - { resolved_objects: [{ saved_object, outcome }] } + * @example + * + * bulkResolve([ + * { id: 'one', type: 'config' }, + * { id: 'foo', type: 'index-pattern' } + * ]) + */ + async bulkResolve( + objects: SavedObjectsBulkResolveObject[], + options: SavedObjectsBaseOptions = {} + ): Promise> { + const { resolved_objects: bulkResults } = await internalBulkResolve({ + registry: this._registry, + allowedTypes: this._allowedTypes, + client: this.client, + serializer: this._serializer, + getIndexForType: this.getIndexForType.bind(this), + incrementCounterInternal: this.incrementCounterInternal.bind(this), + objects, + options, + }); + const resolvedObjects = bulkResults.map>((result) => { + // extract payloads from saved object errors + if ((result as InternalBulkResolveError).error) { + const errorResult = result as InternalBulkResolveError; + const { type, id, error } = errorResult; + return { + saved_object: ({ type, id, error: errorContent(error) } as unknown) as SavedObject, + outcome: 'exactMatch', + }; + } + return result as SavedObjectsResolveResponse; + }); + return { resolved_objects: resolvedObjects }; + } + /** * Gets a single object * @@ -1125,148 +1164,21 @@ export class SavedObjectsRepository { id: string, options: SavedObjectsBaseOptions = {} ): Promise> { - if (!this._allowedTypes.includes(type)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - - const namespace = normalizeNamespace(options.namespace); - if (namespace === undefined) { - // legacy URL aliases cannot exist for the default namespace; just attempt to get the object - return this.resolveExactMatch(type, id, options); - } - - const rawAliasId = this._serializer.generateRawLegacyUrlAliasId(namespace, type, id); - const time = this._getCurrentTime(); - - // retrieve the alias, and if it is not disabled, update it - const aliasResponse = await this.client.update<{ [LEGACY_URL_ALIAS_TYPE]: LegacyUrlAlias }>( - { - id: rawAliasId, - index: this.getIndexForType(LEGACY_URL_ALIAS_TYPE), - refresh: false, - _source: 'true', - body: { - script: { - source: ` - if (ctx._source[params.type].disabled != true) { - if (ctx._source[params.type].resolveCounter == null) { - ctx._source[params.type].resolveCounter = 1; - } - else { - ctx._source[params.type].resolveCounter += 1; - } - ctx._source[params.type].lastResolved = params.time; - ctx._source.updated_at = params.time; - } - `, - lang: 'painless', - params: { - type: LEGACY_URL_ALIAS_TYPE, - time, - }, - }, - }, - }, - { ignore: [404] } - ); - if ( - isNotFoundFromUnsupportedServer({ - statusCode: aliasResponse.statusCode, - headers: aliasResponse.headers, - }) - ) { - // throw if we cannot verify the response is from Elasticsearch - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError( - LEGACY_URL_ALIAS_TYPE, - rawAliasId - ); - } - if ( - aliasResponse.statusCode === 404 || - aliasResponse.body.get?.found === false || - aliasResponse.body.get?._source[LEGACY_URL_ALIAS_TYPE]?.disabled === true - ) { - // no legacy URL alias exists, or one exists but it's disabled; just attempt to get the object - return this.resolveExactMatch(type, id, options); - } - - const legacyUrlAlias: LegacyUrlAlias = aliasResponse.body.get!._source[LEGACY_URL_ALIAS_TYPE]; - const objectIndex = this.getIndexForType(type); - const bulkGetResponse = await this.client.mget( - { - body: { - docs: [ - { - // attempt to find an exact match for the given ID - _id: this._serializer.generateRawId(namespace, type, id), - _index: objectIndex, - }, - { - // also attempt to find a match for the legacy URL alias target ID - _id: this._serializer.generateRawId(namespace, type, legacyUrlAlias.targetId), - _index: objectIndex, - }, - ], - }, - }, - { ignore: [404] } - ); - // exit early if a 404 isn't from elasticsearch - if ( - isNotFoundFromUnsupportedServer({ - statusCode: bulkGetResponse.statusCode, - headers: bulkGetResponse.headers, - }) - ) { - throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(type, id); - } - const exactMatchDoc = bulkGetResponse?.body.docs[0]; - const aliasMatchDoc = bulkGetResponse?.body.docs[1]; - const foundExactMatch = - // @ts-expect-error MultiGetHit._source is optional - exactMatchDoc.found && this.rawDocExistsInNamespace(exactMatchDoc, namespace); - const foundAliasMatch = - // @ts-expect-error MultiGetHit._source is optional - aliasMatchDoc.found && this.rawDocExistsInNamespace(aliasMatchDoc, namespace); - - let result: SavedObjectsResolveResponse | null = null; - let outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND; - if (foundExactMatch && foundAliasMatch) { - result = { - // @ts-expect-error MultiGetHit._source is optional - saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc), - outcome: 'conflict', - alias_target_id: legacyUrlAlias.targetId, - }; - outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT; - } else if (foundExactMatch) { - result = { - // @ts-expect-error MultiGetHit._source is optional - saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc), - outcome: 'exactMatch', - }; - outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH; - } else if (foundAliasMatch) { - result = { - saved_object: getSavedObjectFromSource( - this._registry, - type, - legacyUrlAlias.targetId, - // @ts-expect-error MultiGetHit._source is optional - aliasMatchDoc - ), - outcome: 'aliasMatch', - alias_target_id: legacyUrlAlias.targetId, - }; - outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH; - } - - await this.incrementResolveOutcomeStats(outcomeStatString); - - if (result !== null) { - return result; + const { resolved_objects: bulkResults } = await internalBulkResolve({ + registry: this._registry, + allowedTypes: this._allowedTypes, + client: this.client, + serializer: this._serializer, + getIndexForType: this.getIndexForType.bind(this), + incrementCounterInternal: this.incrementCounterInternal.bind(this), + objects: [{ type, id }], + options, + }); + const [result] = bulkResults; + if ((result as InternalBulkResolveError).error) { + throw (result as InternalBulkResolveError).error; } - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + return result as SavedObjectsResolveResponse; } /** @@ -1298,7 +1210,7 @@ export class SavedObjectsRepository { preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); } - const time = this._getCurrentTime(); + const time = getCurrentTime(); let rawUpsert: SavedObjectsRawDoc | undefined; if (upsert) { @@ -1436,17 +1348,19 @@ export class SavedObjectsRepository { objects: Array>, options: SavedObjectsBulkUpdateOptions = {} ): Promise> { - const time = this._getCurrentTime(); + const time = getCurrentTime(); const namespace = normalizeNamespace(options.namespace); let bulkGetRequestIndexCounter = 0; - const expectedBulkGetResults: Either[] = objects.map((object) => { + const expectedBulkGetResults: Array< + Either, Record> + > = objects.map((object) => { const { type, id } = object; if (!this._allowedTypes.includes(type)) { return { - tag: 'Left' as 'Left', - error: { + tag: 'Left', + value: { id, type, error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), @@ -1458,8 +1372,8 @@ export class SavedObjectsRepository { if (objectNamespace === ALL_NAMESPACES_STRING) { return { - tag: 'Left' as 'Left', - error: { + tag: 'Left', + value: { id, type, error: errorContent( @@ -1480,7 +1394,7 @@ export class SavedObjectsRepository { const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type); return { - tag: 'Right' as 'Right', + tag: 'Right', value: { type, id, @@ -1531,78 +1445,78 @@ export class SavedObjectsRepository { } let bulkUpdateRequestIndexCounter = 0; const bulkUpdateParams: object[] = []; - const expectedBulkUpdateResults: Either[] = expectedBulkGetResults.map( - (expectedBulkGetResult) => { - if (isLeft(expectedBulkGetResult)) { - return expectedBulkGetResult; - } + const expectedBulkUpdateResults: Array< + Either, Record> + > = expectedBulkGetResults.map((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 && actualResult?.found === true; - if ( - !docFound || - // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - !this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace)) - ) { - return { - tag: 'Left' as 'Left', - error: { - id, - type, - error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), - }, - }; - } + 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 && actualResult?.found === true; + if ( + !docFound || // @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), - ]; + !this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace)) + ) { + return { + tag: 'Left', + value: { + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + }, + }; + } + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + namespaces = actualResult!._source.namespaces ?? [ // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - versionProperties = getExpectedVersionProperties(version, actualResult!); - } else { - if (this._registry.isSingleNamespace(type)) { - // if `objectNamespace` is undefined, fall back to `options.namespace` - namespaces = [getNamespaceString(objectNamespace)]; - } - versionProperties = getExpectedVersionProperties(version); + SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), + ]; + // @ts-expect-error MultiGetHit is incorrectly missing _id, _source + versionProperties = getExpectedVersionProperties(version, actualResult!); + } else { + if (this._registry.isSingleNamespace(type)) { + // if `objectNamespace` is undefined, fall back to `options.namespace` + namespaces = [getNamespaceString(objectNamespace)]; } + versionProperties = getExpectedVersionProperties(version); + } - const expectedResult = { - type, - id, - namespaces, - esRequestIndex: bulkUpdateRequestIndexCounter++, - documentToSave: expectedBulkGetResult.value.documentToSave, - }; + const expectedResult = { + type, + id, + namespaces, + esRequestIndex: bulkUpdateRequestIndexCounter++, + documentToSave: expectedBulkGetResult.value.documentToSave, + }; - bulkUpdateParams.push( - { - update: { - _id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id), - _index: this.getIndexForType(type), - ...versionProperties, - }, + bulkUpdateParams.push( + { + update: { + _id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id), + _index: this.getIndexForType(type), + ...versionProperties, }, - { doc: documentToSave } - ); + }, + { doc: documentToSave } + ); - return { tag: 'Right' as 'Right', value: expectedResult }; - } - ); + return { tag: 'Right', value: expectedResult }; + }); const { refresh = DEFAULT_REFRESH_SETTING } = options; const bulkUpdateResponse = bulkUpdateParams.length @@ -1617,7 +1531,7 @@ export class SavedObjectsRepository { return { saved_objects: expectedBulkUpdateResults.map((expectedResult) => { if (isLeft(expectedResult)) { - return expectedResult.error as any; + return expectedResult.value as any; } const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; @@ -1837,7 +1751,7 @@ export class SavedObjectsRepository { }); const namespace = normalizeNamespace(options.namespace); - const time = this._getCurrentTime(); + const time = getCurrentTime(); let savedObjectNamespace; let savedObjectNamespaces: string[] | undefined; @@ -2120,10 +2034,6 @@ export class SavedObjectsRepository { return unique(types.map((t) => this.getIndexForType(t))); } - private _getCurrentTime() { - return new Date().toISOString(); - } - private _rawToSavedObject(raw: SavedObjectsRawDoc): SavedObject { const savedObject = this._serializer.rawToSavedObject(raw); const { namespace, type } = savedObject; @@ -2230,33 +2140,6 @@ export class SavedObjectsRepository { return body; } - private async resolveExactMatch( - type: string, - id: string, - options: SavedObjectsBaseOptions - ): Promise> { - try { - const object = await this.get(type, id, options); - await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); - return { saved_object: object, outcome: 'exactMatch' }; - } catch (err) { - if (SavedObjectsErrorHelpers.isNotFoundError(err)) { - // 404 responses already confirmed to be valid Elasticsearch responses - await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); - } - throw err; - } - } - - private async incrementResolveOutcomeStats(outcomeStatString: string) { - await this.incrementCounterInternal( - CORE_USAGE_STATS_TYPE, - CORE_USAGE_STATS_ID, - [outcomeStatString, REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL], - { refresh: false } - ).catch(() => {}); // if the call fails for some reason, intentionally swallow the error - } - /** The `initialNamespaces` field (create, bulkCreate) is used to create an object in an initial set of spaces. */ private validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) { if (!initialNamespaces) { @@ -2322,20 +2205,6 @@ function getSavedObjectNamespaces( return [SavedObjectsUtils.namespaceIdToString(namespace)]; } -/** - * Ensure that a namespace is always in its namespace ID representation. - * This allows `'default'` to be used interchangeably with `undefined`. - */ -const normalizeNamespace = (namespace?: string) => { - if (namespace === ALL_NAMESPACES_STRING) { - throw SavedObjectsErrorHelpers.createBadRequestError('"options.namespace" cannot be "*"'); - } else if (namespace === undefined) { - return namespace; - } else { - return SavedObjectsUtils.namespaceStringToId(namespace); - } -}; - /** * Extracts the contents of a decorated error to return the attributes for bulk operations. */ diff --git a/src/core/server/saved_objects/service/lib/update_objects_spaces.ts b/src/core/server/saved_objects/service/lib/update_objects_spaces.ts index 666b7b98b42e51..6d7c272c26eec3 100644 --- a/src/core/server/saved_objects/service/lib/update_objects_spaces.ts +++ b/src/core/server/saved_objects/service/lib/update_objects_spaces.ts @@ -22,6 +22,9 @@ import { getBulkOperationError, getExpectedVersionProperties, rawDocExistsInNamespace, + Either, + isLeft, + isRight, } from './internal_utils'; import { DEFAULT_REFRESH_SETTING } from './repository'; import type { RepositoryEsClient } from './repository_es_client'; @@ -86,14 +89,6 @@ export interface SavedObjectsUpdateObjectsSpacesResponseObject { error?: SavedObjectError; } -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Left = { tag: 'Left'; error: SavedObjectsUpdateObjectsSpacesResponseObject }; -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Right = { tag: 'Right'; value: Record }; -type Either = Left | Right; -const isLeft = (either: Either): either is Left => either.tag === 'Left'; -const isRight = (either: Either): either is Right => either.tag === 'Right'; - /** * Parameters for the updateObjectsSpaces function. * @@ -140,14 +135,16 @@ export async function updateObjectsSpaces({ const { namespace } = options; let bulkGetRequestIndexCounter = 0; - const expectedBulkGetResults: Either[] = objects.map((object) => { + const expectedBulkGetResults: Array< + Either> + > = objects.map((object) => { const { type, id, spaces, version } = object; if (!allowedTypes.includes(type)) { const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); return { - tag: 'Left' as 'Left', - error: { id, type, spaces: [], error }, + tag: 'Left', + value: { id, type, spaces: [], error }, }; } if (!registry.isShareable(type)) { @@ -157,13 +154,13 @@ export async function updateObjectsSpaces({ ) ); return { - tag: 'Left' as 'Left', - error: { id, type, spaces: [], error }, + tag: 'Left', + value: { id, type, spaces: [], error }, }; } return { - tag: 'Right' as 'Right', + tag: 'Right', value: { type, id, @@ -204,71 +201,71 @@ export async function updateObjectsSpaces({ const time = new Date().toISOString(); let bulkOperationRequestIndexCounter = 0; const bulkOperationParams: estypes.BulkOperationContainer[] = []; - const expectedBulkOperationResults: Either[] = expectedBulkGetResults.map( - (expectedBulkGetResult) => { - if (isLeft(expectedBulkGetResult)) { - return expectedBulkGetResult; - } + const expectedBulkOperationResults: Array< + Either> + > = expectedBulkGetResults.map((expectedBulkGetResult) => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } - const { id, type, spaces, version, esRequestIndex } = expectedBulkGetResult.value; + const { id, type, spaces, version, esRequestIndex } = expectedBulkGetResult.value; - let currentSpaces: string[] = spaces; - let versionProperties; - if (esRequestIndex !== undefined) { - const doc = bulkGetResponse?.body.docs[esRequestIndex]; - // @ts-expect-error MultiGetHit._source is optional - if (!doc?.found || !rawDocExistsInNamespace(registry, doc, namespace)) { - const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); - return { - tag: 'Left' as 'Left', - error: { id, type, spaces: [], error }, - }; - } - currentSpaces = doc._source?.namespaces ?? []; - // @ts-expect-error MultiGetHit._source is optional - versionProperties = getExpectedVersionProperties(version, doc); - } else if (spaces?.length === 0) { - // A SOC wrapper attempted to retrieve this object in a pre-flight request and it was not found. + let currentSpaces: string[] = spaces; + let versionProperties; + if (esRequestIndex !== undefined) { + const doc = bulkGetResponse?.body.docs[esRequestIndex]; + // @ts-expect-error MultiGetHit._source is optional + if (!doc?.found || !rawDocExistsInNamespace(registry, doc, namespace)) { const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); return { - tag: 'Left' as 'Left', - error: { id, type, spaces: [], error }, + tag: 'Left', + value: { id, type, spaces: [], error }, }; - } else { - versionProperties = getExpectedVersionProperties(version); } - - const { newSpaces, isUpdateRequired } = getNewSpacesArray( - currentSpaces, - spacesToAdd, - spacesToRemove - ); - const expectedResult = { - type, - id, - newSpaces, - ...(isUpdateRequired && { esRequestIndex: bulkOperationRequestIndexCounter++ }), + currentSpaces = doc._source?.namespaces ?? []; + // @ts-expect-error MultiGetHit._source is optional + versionProperties = getExpectedVersionProperties(version, doc); + } else if (spaces?.length === 0) { + // A SOC wrapper attempted to retrieve this object in a pre-flight request and it was not found. + const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); + return { + tag: 'Left', + value: { id, type, spaces: [], error }, }; + } else { + versionProperties = getExpectedVersionProperties(version); + } - if (isUpdateRequired) { - const documentMetadata = { - _id: serializer.generateRawId(undefined, type, id), - _index: getIndexForType(type), - ...versionProperties, - }; - if (newSpaces.length) { - const documentToSave = { updated_at: time, namespaces: newSpaces }; - // @ts-expect-error BulkOperation.retry_on_conflict, BulkOperation.routing. BulkOperation.version, and BulkOperation.version_type are optional - bulkOperationParams.push({ update: documentMetadata }, { doc: documentToSave }); - } else { - // @ts-expect-error BulkOperation.retry_on_conflict, BulkOperation.routing. BulkOperation.version, and BulkOperation.version_type are optional - bulkOperationParams.push({ delete: documentMetadata }); - } - } + const { newSpaces, isUpdateRequired } = getNewSpacesArray( + currentSpaces, + spacesToAdd, + spacesToRemove + ); + const expectedResult = { + type, + id, + newSpaces, + ...(isUpdateRequired && { esRequestIndex: bulkOperationRequestIndexCounter++ }), + }; - return { tag: 'Right' as 'Right', value: expectedResult }; + if (isUpdateRequired) { + const documentMetadata = { + _id: serializer.generateRawId(undefined, type, id), + _index: getIndexForType(type), + ...versionProperties, + }; + if (newSpaces.length) { + const documentToSave = { updated_at: time, namespaces: newSpaces }; + // @ts-expect-error BulkOperation.retry_on_conflict, BulkOperation.routing. BulkOperation.version, and BulkOperation.version_type are optional + bulkOperationParams.push({ update: documentMetadata }, { doc: documentToSave }); + } else { + // @ts-expect-error BulkOperation.retry_on_conflict, BulkOperation.routing. BulkOperation.version, and BulkOperation.version_type are optional + bulkOperationParams.push({ delete: documentMetadata }); + } } - ); + + return { tag: 'Right', value: expectedResult }; + }); const { refresh = DEFAULT_REFRESH_SETTING } = options; const bulkOperationResponse = bulkOperationParams.length @@ -279,7 +276,7 @@ export async function updateObjectsSpaces({ objects: expectedBulkOperationResults.map( (expectedResult) => { if (isLeft(expectedResult)) { - return expectedResult.error; + return expectedResult.value; } const { type, id, newSpaces, esRequestIndex } = expectedResult.value; diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index e02387d41addf8..4f96c5c0d8caef 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -24,6 +24,7 @@ const create = () => { closePointInTime: jest.fn(), createPointInTimeFinder: jest.fn(), openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), + bulkResolve: jest.fn(), resolve: jest.fn(), update: jest.fn(), removeReferencesTo: jest.fn(), diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 1a369475f2c6d7..736e6e06da905a 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -184,6 +184,21 @@ test(`#closePointInTime`, async () => { expect(result).toBe(returnValue); }); +test(`#bulkResolve`, async () => { + const returnValue = Symbol(); + const mockRepository = { + bulkResolve: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const objects = Symbol(); + const options = Symbol(); + const result = await client.bulkResolve(objects, options); + + expect(mockRepository.bulkResolve).toHaveBeenCalledWith(objects, options); + expect(result).toBe(returnValue); +}); + test(`#resolve`, async () => { const returnValue = Symbol(); const mockRepository = { diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index dd9a8393e0624d..de788c8e659859 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -317,6 +317,23 @@ export interface SavedObjectsUpdateResponse references: SavedObjectReference[] | undefined; } +/** + * + * @public + */ +export interface SavedObjectsBulkResolveObject { + id: string; + type: string; +} + +/** + * + * @public + */ +export interface SavedObjectsBulkResolveResponse { + resolved_objects: Array>; +} + /** * * @public @@ -503,6 +520,28 @@ export class SavedObjectsClient { return await this._repository.get(type, id, options); } + /** + * Resolves an array of objects by id, using any legacy URL aliases if they exist + * + * @param objects - an array of objects containing id, type + * @example + * + * bulkResolve([ + * { id: 'one', type: 'config' }, + * { id: 'foo', type: 'index-pattern' } + * ]) + * + * @note Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the + * outcome is that "exactMatch" is the default outcome, and the outcome only changes if an alias is found. This behavior is unique to + * `bulkResolve`; the regular `resolve` API will throw an error instead. + */ + async bulkResolve( + objects: SavedObjectsBulkResolveObject[], + options?: SavedObjectsBaseOptions + ): Promise> { + return await this._repository.bulkResolve(objects, options); + } + /** * Resolves a single object, using any legacy URL alias if it exists * diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index ea2b9dde949b2f..e6dffa23276f92 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -548,6 +548,20 @@ export interface CoreUsageStats { // (undocumented) 'apiCalls.savedObjectsBulkGet.total'?: number; // (undocumented) + 'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsBulkResolve.namespace.custom.total'?: number; + // (undocumented) + 'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.no'?: number; + // (undocumented) + 'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.yes'?: number; + // (undocumented) + 'apiCalls.savedObjectsBulkResolve.namespace.default.total'?: number; + // (undocumented) + 'apiCalls.savedObjectsBulkResolve.total'?: number; + // (undocumented) 'apiCalls.savedObjectsBulkUpdate.namespace.custom.kibanaRequest.no'?: number; // (undocumented) 'apiCalls.savedObjectsBulkUpdate.namespace.custom.kibanaRequest.yes'?: number; @@ -1956,6 +1970,20 @@ export interface SavedObjectsBulkGetObject { type: string; } +// @public (undocumented) +export interface SavedObjectsBulkResolveObject { + // (undocumented) + id: string; + // (undocumented) + type: string; +} + +// @public (undocumented) +export interface SavedObjectsBulkResolveResponse { + // (undocumented) + resolved_objects: Array>; +} + // @public (undocumented) export interface SavedObjectsBulkResponse { // (undocumented) @@ -2011,6 +2039,7 @@ export class SavedObjectsClient { constructor(repository: ISavedObjectsRepository); bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; + bulkResolve(objects: SavedObjectsBulkResolveObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; @@ -2609,6 +2638,7 @@ export interface SavedObjectsRemoveReferencesToResponse extends SavedObjectsBase export class SavedObjectsRepository { bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; + bulkResolve(objects: SavedObjectsBulkResolveObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts index 853681c47cf857..b1cf0ecd2213ee 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -495,6 +495,46 @@ export function getCoreUsageCollector( 'How many times this API has been called by a non-Kibana client in a custom space.', }, }, + 'apiCalls.savedObjectsBulkResolve.total': { + type: 'long', + _meta: { description: 'How many times this API has been called.' }, + }, + 'apiCalls.savedObjectsBulkResolve.namespace.default.total': { + type: 'long', + _meta: { description: 'How many times this API has been called in the Default space.' }, + }, + 'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.yes': { + type: 'long', + _meta: { + description: + 'How many times this API has been called by the Kibana client in the Default space.', + }, + }, + 'apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.no': { + type: 'long', + _meta: { + description: + 'How many times this API has been called by a non-Kibana client in the Default space.', + }, + }, + 'apiCalls.savedObjectsBulkResolve.namespace.custom.total': { + type: 'long', + _meta: { description: 'How many times this API has been called in a custom space.' }, + }, + 'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.yes': { + type: 'long', + _meta: { + description: + 'How many times this API has been called by the Kibana client in a custom space.', + }, + }, + 'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.no': { + type: 'long', + _meta: { + description: + 'How many times this API has been called by a non-Kibana client in a custom space.', + }, + }, 'apiCalls.savedObjectsBulkUpdate.total': { type: 'long', _meta: { description: 'How many times this API has been called.' }, diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 666ba04654e156..0a81da0cdceba2 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -6263,6 +6263,48 @@ "description": "How many times this API has been called by a non-Kibana client in a custom space." } }, + "apiCalls.savedObjectsBulkResolve.total": { + "type": "long", + "_meta": { + "description": "How many times this API has been called." + } + }, + "apiCalls.savedObjectsBulkResolve.namespace.default.total": { + "type": "long", + "_meta": { + "description": "How many times this API has been called in the Default space." + } + }, + "apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.yes": { + "type": "long", + "_meta": { + "description": "How many times this API has been called by the Kibana client in the Default space." + } + }, + "apiCalls.savedObjectsBulkResolve.namespace.default.kibanaRequest.no": { + "type": "long", + "_meta": { + "description": "How many times this API has been called by a non-Kibana client in the Default space." + } + }, + "apiCalls.savedObjectsBulkResolve.namespace.custom.total": { + "type": "long", + "_meta": { + "description": "How many times this API has been called in a custom space." + } + }, + "apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.yes": { + "type": "long", + "_meta": { + "description": "How many times this API has been called by the Kibana client in a custom space." + } + }, + "apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.no": { + "type": "long", + "_meta": { + "description": "How many times this API has been called by a non-Kibana client in a custom space." + } + }, "apiCalls.savedObjectsBulkUpdate.total": { "type": "long", "_meta": { diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 10a645295e2dec..0d958d29b2150e 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { SavedObjectsClientContract } from 'src/core/server'; +import type { SavedObjectsBulkResolveResponse, SavedObjectsClientContract } from 'src/core/server'; import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from 'src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock'; @@ -1454,6 +1454,241 @@ describe('#get', () => { }); }); +describe('#bulkResolve', () => { + it('redirects request to underlying base client and does not alter response if type is not registered', async () => { + const mockedResponse = { + resolved_objects: [ + { + saved_object: { + id: 'some-id', + type: 'unknown-type', + attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + references: [], + }, + }, + { + saved_object: { + id: 'some-id-2', + type: 'unknown-type', + attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + references: [], + }, + }, + ], + }; + + mockBaseClient.bulkResolve.mockResolvedValue( + (mockedResponse as unknown) as SavedObjectsBulkResolveResponse + ); + + const bulkResolveParams = [ + { type: 'unknown-type', id: 'some-id' }, + { type: 'unknown-type', id: 'some-id-2' }, + ]; + + const options = { namespace: 'some-ns' }; + await expect(wrapper.bulkResolve(bulkResolveParams, options)).resolves.toEqual(mockedResponse); + expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith(bulkResolveParams, options); + }); + + it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { + const mockedResponse = { + resolved_objects: [ + { + saved_object: { + id: 'some-id', + type: 'unknown-type', + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + namespaces: ['some-ns'], + references: [], + }, + }, + { + saved_object: { + id: 'some-id-2', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + namespaces: ['some-ns'], + references: [], + }, + }, + ], + }; + + mockBaseClient.bulkResolve.mockResolvedValue( + (mockedResponse as unknown) as SavedObjectsBulkResolveResponse + ); + + const bulkResolveParams = [ + { type: 'unknown-type', id: 'some-id' }, + { type: 'known-type', id: 'some-id-2' }, + ]; + + const options = { namespace: 'some-ns' }; + await expect(wrapper.bulkResolve(bulkResolveParams, options)).resolves.toEqual({ + resolved_objects: [ + mockedResponse.resolved_objects[0], + { + saved_object: { + ...mockedResponse.resolved_objects[1].saved_object, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, + }, + }, + ], + }); + expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith(bulkResolveParams, options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id-2', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); + }); + + it('includes both attributes and error if decryption fails.', async () => { + const mockedResponse = { + resolved_objects: [ + { + saved_object: { + id: 'some-id', + type: 'unknown-type', + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + namespaces: ['some-ns'], + references: [], + }, + }, + { + saved_object: { + id: 'some-id-2', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + namespaces: ['some-ns'], + references: [], + }, + }, + ], + }; + + mockBaseClient.bulkResolve.mockResolvedValue( + (mockedResponse as unknown) as SavedObjectsBulkResolveResponse + ); + + const decryptionError = new EncryptionError( + 'something failed', + 'attrNotSoSecret', + EncryptionErrorOperation.Decryption + ); + encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }); + + const bulkResolveParams = [ + { type: 'unknown-type', id: 'some-id' }, + { type: 'known-type', id: 'some-id-2' }, + ]; + + const options = { namespace: 'some-ns' }; + await expect(wrapper.bulkResolve(bulkResolveParams, options)).resolves.toEqual({ + resolved_objects: [ + mockedResponse.resolved_objects[0], + { + saved_object: { + ...mockedResponse.resolved_objects[1].saved_object, + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }, + }, + ], + }); + expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith(bulkResolveParams, options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id-2', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); + }); + + it('fails if base client fails', async () => { + const failureReason = new Error('Something bad happened...'); + mockBaseClient.bulkResolve.mockRejectedValue(failureReason); + + await expect(wrapper.bulkResolve([{ type: 'known-type', id: 'some-id' }])).rejects.toThrowError( + failureReason + ); + + expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.bulkResolve).toHaveBeenCalledWith( + [{ type: 'known-type', id: 'some-id' }], + undefined + ); + }); + + it('redirects request to underlying base client and return errors result if type is registered', async () => { + const mockedResponse = { + resolved_objects: [ + { + saved_object: { + id: 'bad', + type: 'known-type', + error: { statusCode: 404, message: 'Not found' }, + }, + }, + ], + }; + mockBaseClient.bulkResolve.mockResolvedValue( + (mockedResponse as unknown) as SavedObjectsBulkResolveResponse + ); + const bulkGetParams = [{ type: 'known-type', id: 'bad' }]; + + const options = { namespace: 'some-ns' }; + await expect(wrapper.bulkResolve(bulkGetParams, options)).resolves.toEqual(mockedResponse); + expect(mockBaseClient.bulkResolve).toHaveBeenCalledTimes(1); + }); +}); + describe('#resolve', () => { it('redirects request to underlying base client and does not alter response if type is not registered', async () => { const mockedResponse = { diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index a339f213bdce4b..79f34c13d511c2 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -11,6 +11,7 @@ import type { SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, + SavedObjectsBulkResolveObject, SavedObjectsBulkResponse, SavedObjectsBulkUpdateObject, SavedObjectsBulkUpdateResponse, @@ -190,6 +191,28 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon ); } + public async bulkResolve( + objects: SavedObjectsBulkResolveObject[], + options?: SavedObjectsBaseOptions + ) { + const bulkResolveResult = await this.options.baseClient.bulkResolve(objects, options); + + for (const resolved of bulkResolveResult.resolved_objects) { + const savedObject = resolved.saved_object; + await this.handleEncryptedAttributesInResponse( + savedObject, + undefined as unknown, + getDescriptorNamespace( + this.options.baseTypeRegistry, + savedObject.type, + savedObject.namespaces ? savedObject.namespaces[0] : undefined + ) + ); + } + + return bulkResolveResult; + } + public async resolve(type: string, id: string, options?: SavedObjectsBaseOptions) { const resolveResult = await this.options.baseClient.resolve(type, id, options); const object = await this.handleEncryptedAttributesInResponse( diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 96f8c5ff02d244..59a2a38cd42f19 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -12,6 +12,7 @@ import type { SavedObject, SavedObjectReferenceWithContext, SavedObjectsClientContract, + SavedObjectsResolveResponse, SavedObjectsUpdateObjectsSpacesResponseObject, } from 'src/core/server'; import { httpServerMock, savedObjectsClientMock } from 'src/core/server/mocks'; @@ -465,6 +466,103 @@ describe('#bulkGet', () => { }); }); +describe('#bulkResolve', () => { + const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); + const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); + const namespace = 'some-ns'; + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const objects = [obj1]; + await expectGeneralError(client.bulkResolve, { objects }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const objects = [obj1, obj2]; + const options = { namespace }; + await expectForbiddenError(client.bulkResolve, { objects, options }, 'bulk_resolve'); + }); + + test(`returns result of baseClient.bulkResolve when authorized`, async () => { + const apiCallReturnValue = { resolved_objects: [] }; + clientOpts.baseClient.bulkResolve.mockResolvedValue(apiCallReturnValue); + + const objects = [obj1, obj2]; + const options = { namespace }; + const result = await expectSuccess(client.bulkResolve, { objects, options }, 'bulk_resolve'); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const objects = [obj1, obj2]; + const options = { namespace }; + await expectPrivilegeCheck(client.bulkResolve, { objects, options }, namespace); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const objects = [obj1, obj2]; + const options = { namespace }; + + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // privilege check for authorization + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // privilege check for namespace filtering + ); + + clientOpts.baseClient.bulkResolve.mockResolvedValue({ + resolved_objects: [ + // omit other fields from the SavedObjectsResolveResponse such as outcome, as they are not needed for this test case + ({ saved_object: { namespaces: ['*'] } } as unknown) as SavedObjectsResolveResponse, + ({ saved_object: { namespaces: [namespace] } } as unknown) as SavedObjectsResolveResponse, + ({ + saved_object: { namespaces: ['some-other-namespace', namespace] }, + } as unknown) as SavedObjectsResolveResponse, + ], + }); + + const result = await client.bulkResolve(objects, options); + expect(result).toEqual({ + resolved_objects: [ + { saved_object: { namespaces: ['*'] } }, + { saved_object: { namespaces: [namespace] } }, + { saved_object: { namespaces: [namespace, '?'] } }, + ], + }); + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( + 'login:', + ['some-other-namespace'] + // when we check what namespaces to redact, we don't check privileges for '*', only actual space IDs + // we don't check privileges for authorizedNamespaces either, as that was already checked earlier in the operation + ); + }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = { + resolved_objects: [ + ({ saved_object: obj1 } as unknown) as SavedObjectsResolveResponse, + ({ saved_object: obj2 } as unknown) as SavedObjectsResolveResponse, + ], + }; + clientOpts.baseClient.bulkResolve.mockResolvedValue(apiCallReturnValue); + const objects = [obj1, obj2]; + const options = { namespace }; + await expectSuccess(client.bulkResolve, { objects, options }, 'bulk_resolve'); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); + expectAuditEvent('saved_object_resolve', 'success', obj1); + expectAuditEvent('saved_object_resolve', 'success', obj2); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.bulkResolve([obj1, obj2], { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2); + expectAuditEvent('saved_object_resolve', 'failure', obj1); + expectAuditEvent('saved_object_resolve', 'failure', obj2); + }); +}); + describe('#bulkUpdate', () => { const obj1 = Object.freeze({ type: 'foo', id: 'foo-id', attributes: { some: 'attr' } }); const obj2 = Object.freeze({ type: 'bar', id: 'bar-id', attributes: { other: 'attr' } }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 11eca287cd4f57..b0428be87a4f21 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -11,6 +11,7 @@ import type { SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, + SavedObjectsBulkResolveObject, SavedObjectsBulkUpdateObject, SavedObjectsCheckConflictsObject, SavedObjectsClientContract, @@ -356,6 +357,78 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]); } + public async bulkResolve( + objects: SavedObjectsBulkResolveObject[], + options: SavedObjectsBaseOptions = {} + ) { + try { + const args = { objects, options }; + await this.legacyEnsureAuthorized( + this.getUniqueObjectTypes(objects), + 'bulk_get', + options.namespace, + { args, auditAction: 'bulk_resolve' } + ); + } catch (error) { + objects.forEach(({ type, id }) => + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type, id }, + error, + }) + ) + ); + throw error; + } + + const response = await this.baseClient.bulkResolve(objects, options); + + response.resolved_objects.forEach(({ saved_object: { error, type, id } }) => { + if (!error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type, id }, + }) + ); + } + }); + + // the generic redactSavedObjectsNamespaces function cannot be used here due to the nested structure of the + // resolved objects, so we handle redaction in a bespoke manner for bulkResolve + + if (this.getSpacesService() === undefined) { + return response; + } + + const previouslyAuthorizedSpaceIds = [ + this.getSpacesService()!.namespaceToSpaceId(options.namespace), + ]; + // all users can see the "all spaces" ID, and we don't need to recheck authorization for any namespaces that we just checked earlier + const namespaces = uniq( + response.resolved_objects.flatMap((resolved) => resolved.saved_object.namespaces || []) + ).filter((x) => x !== ALL_SPACES_ID && !previouslyAuthorizedSpaceIds.includes(x)); + + const privilegeMap = await this.getNamespacesPrivilegeMap( + namespaces, + previouslyAuthorizedSpaceIds + ); + + return { + ...response, + resolved_objects: response.resolved_objects.map((resolved) => ({ + ...resolved, + saved_object: { + ...resolved.saved_object, + namespaces: + resolved.saved_object.namespaces && + this.redactAndSortNamespaces(resolved.saved_object.namespaces, privilegeMap), + }, + })), + }; + } + public async resolve( type: string, id: string, @@ -1030,6 +1103,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra response: T, previouslyAuthorizedNamespaces: Array ): Promise { + // WARNING: the bulkResolve function has a bespoke implementation of this; any changes here should be applied there too. + if (this.getSpacesService() === undefined) { return response; } diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 85c6ce74763b20..20fbaa46028c8f 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -93,6 +93,32 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); + describe('#bulkResolve', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.bulkResolve([], { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { resolved_objects: [] }; + baseClient.bulkResolve.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.bulkResolve([], options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.bulkResolve).toHaveBeenCalledWith([], { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + describe('#resolve', () => { test(`throws error if options.namespace is specified`, async () => { const { client } = createSpacesSavedObjectsClient(); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 6cfd784042317a..e2e3e856f74f0d 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -13,6 +13,7 @@ import type { SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, + SavedObjectsBulkResolveObject, SavedObjectsBulkUpdateObject, SavedObjectsCheckConflictsObject, SavedObjectsClientContract, @@ -92,14 +93,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { this.errors = baseClient.errors; } - /** - * Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are - * multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten. - * - * @param objects - * @param options - */ - public async checkConflicts( + async checkConflicts( objects: SavedObjectsCheckConflictsObject[] = [], options: SavedObjectsBaseOptions = {} ) { @@ -111,18 +105,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Persists an object - * - * @param {string} type - * @param {object} attributes - * @param {object} [options={}] - * @property {string} [options.id] - force id on creation, not recommended - * @property {boolean} [options.overwrite=false] - * @property {string} [options.namespace] - * @returns {promise} - { id, type, version, attributes } - */ - public async create( + async create( type: string, attributes: T = {} as T, options: SavedObjectsCreateOptions = {} @@ -135,16 +118,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Creates multiple documents at once - * - * @param {array} objects - [{ type, id, attributes }] - * @param {object} [options={}] - * @property {boolean} [options.overwrite=false] - overwrites existing documents - * @property {string} [options.namespace] - * @returns {promise} - { saved_objects: [{ id, type, version, attributes, error: { message } }]} - */ - public async bulkCreate( + async bulkCreate( objects: Array>, options: SavedObjectsBaseOptions = {} ) { @@ -156,16 +130,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Deletes an object - * - * @param {string} type - * @param {string} id - * @param {object} [options={}] - * @property {string} [options.namespace] - * @returns {promise} - */ - public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { throwErrorIfNamespaceSpecified(options); return await this.client.delete(type, id, { @@ -174,23 +139,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * @param {object} [options={}] - * @property {(string|Array)} [options.type] - * @property {string} [options.search] - * @property {string} [options.defaultSearchOperator] - * @property {Array} [options.searchFields] - see Elasticsearch Simple Query String - * Query field argument for more information - * @property {integer} [options.page=1] - * @property {integer} [options.perPage=20] - * @property {string} [options.sortField] - * @property {string} [options.sortOrder] - * @property {Array} [options.fields] - * @property {string} [options.namespaces] - * @property {object} [options.hasReference] - { type, id } - * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } - */ - public async find(options: SavedObjectsFindOptions) { + async find(options: SavedObjectsFindOptions) { let namespaces: string[]; try { namespaces = await this.getSearchableSpaces(options.namespaces); @@ -215,21 +164,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Returns an array of objects by id - * - * @param {array} objects - an array ids, or an array of objects containing id and optionally type - * @param {object} [options={}] - * @property {string} [options.namespace] - * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } - * @example - * - * bulkGet([ - * { id: 'one', type: 'config' }, - * { id: 'foo', type: 'index-pattern' } - * ]) - */ - public async bulkGet( + async bulkGet( objects: SavedObjectsBulkGetObject[] = [], options: SavedObjectsBaseOptions = {} ) { @@ -292,16 +227,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }; } - /** - * Gets a single object - * - * @param {string} type - * @param {string} id - * @param {object} [options={}] - * @property {string} [options.namespace] - * @returns {promise} - { id, type, version, attributes } - */ - public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { throwErrorIfNamespaceSpecified(options); return await this.client.get(type, id, { @@ -310,39 +236,28 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Resolves a single object, using any legacy URL alias if it exists - * - * @param type - The type of SavedObject to retrieve - * @param id - The ID of the SavedObject to retrieve - * @param {object} [options={}] - * @property {string} [options.namespace] - * @returns {promise} - { saved_object, outcome } - */ - public async resolve( - type: string, - id: string, + async bulkResolve( + objects: SavedObjectsBulkResolveObject[], options: SavedObjectsBaseOptions = {} ) { throwErrorIfNamespaceSpecified(options); + return await this.client.bulkResolve(objects, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + + async resolve(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + throwErrorIfNamespaceSpecified(options); + return await this.client.resolve(type, id, { ...options, namespace: spaceIdToNamespace(this.spaceId), }); } - /** - * Updates an object - * - * @param {string} type - * @param {string} id - * @param {object} [options={}] - * @property {string} options.version - ensures version matches that of persisted object - * @property {string} [options.namespace] - * @returns {promise} - */ - public async update( + async update( type: string, id: string, attributes: Partial, @@ -356,19 +271,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Updates an array of objects by id - * - * @param {array} objects - an array ids, or an array of objects containing id, type, attributes and optionally version, references and namespace - * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } - * @example - * - * bulkUpdate([ - * { id: 'one', type: 'config', attributes: { title: 'My new title'}, version: 'd7rhfk47d=' }, - * { id: 'foo', type: 'index-pattern', attributes: {} } - * ]) - */ - public async bulkUpdate( + async bulkUpdate( objects: Array> = [], options: SavedObjectsBaseOptions = {} ) { @@ -379,14 +282,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Remove outward references to given object. - * - * @param type - * @param id - * @param options - */ - public async removeReferencesTo( + async removeReferencesTo( type: string, id: string, options: SavedObjectsRemoveReferencesToOptions = {} @@ -398,13 +294,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Gets all references and transitive references of the listed objects. Ignores any object that is not a multi-namespace type. - * - * @param objects - * @param options - */ - public async collectMultiNamespaceReferences( + async collectMultiNamespaceReferences( objects: SavedObjectsCollectMultiNamespaceReferencesObject[], options: SavedObjectsCollectMultiNamespaceReferencesOptions = {} ): Promise { @@ -415,15 +305,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Updates one or more objects to add and/or remove them from specified spaces. - * - * @param objects - * @param spacesToAdd - * @param spacesToRemove - * @param options - */ - public async updateObjectsSpaces( + async updateObjectsSpaces( objects: SavedObjectsUpdateObjectsSpacesObject[], spacesToAdd: string[], spacesToRemove: string[], @@ -436,16 +318,6 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Opens a Point In Time (PIT) against the indices for the specified Saved Object types. - * The returned `id` can then be passed to `SavedObjects.find` to search against that PIT. - * - * @param {string|Array} type - * @param {object} [options] - {@link SavedObjectsOpenPointInTimeOptions} - * @property {string} [options.keepAlive] - * @property {string} [options.preference] - * @returns {promise} - { id: string } - */ async openPointInTimeForType( type: string | string[], options: SavedObjectsOpenPointInTimeOptions = {} @@ -471,15 +343,6 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Closes a Point In Time (PIT) by ID. This simply proxies the request to ES - * via the Elasticsearch client, and is included in the Saved Objects Client - * as a convenience for consumers who are using `openPointInTimeForType`. - * - * @param {string} id - ID returned from `openPointInTimeForType` - * @param {object} [options] - {@link SavedObjectsClosePointInTimeOptions} - * @returns {promise} - { succeeded: boolean; num_freed: number } - */ async closePointInTime(id: string, options: SavedObjectsClosePointInTimeOptions = {}) { throwErrorIfNamespaceSpecified(options); return await this.client.closePointInTime(id, { @@ -488,17 +351,6 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } - /** - * Returns a generator to help page through large sets of saved objects. - * - * The generator wraps calls to `SavedObjects.find` and iterates over - * multiple pages of results using `_pit` and `search_after`. This will - * open a new Point In Time (PIT), and continue paging until a set of - * results is received that's smaller than the designated `perPage`. - * - * @param {object} findOptions - {@link SavedObjectsCreatePointInTimeFinderOptions} - * @param {object} [dependencies] - {@link SavedObjectsCreatePointInTimeFinderDependencies} - */ createPointInTimeFinder( findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index 06758da1ebad27..2ada63d4b6323b 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -115,6 +115,7 @@ export function bulkCreateTestSuiteFactory(esArchiver: any, supertest: SuperTest expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); const redactedNamespaces = getRedactedNamespaces(user, testCase.expectedNamespaces); expect(object.namespaces).to.eql(redactedNamespaces); + // TODO: improve assertions for redacted namespaces? (#112455) } } } diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts index abfb1f12a2771b..d33270787fae50 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts @@ -51,7 +51,7 @@ export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest; +} +export type BulkResolveTestSuite = TestSuite; +export interface BulkResolveTestCase extends TestCase { + expectedOutcome?: 'exactMatch' | 'aliasMatch' | 'conflict'; + expectedId?: string; + expectedAliasTargetId?: string; +} + +export { TEST_CASES }; // re-export the (non-bulk) resolve test cases + +export function bulkResolveTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_get'); + const expectResponseBody = ( + testCases: BulkResolveTestCase | BulkResolveTestCase[], + statusCode: 200 | 403 + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map((x) => x.type); + await expectSavedObjectForbidden(types)(response); + } else { + // permitted + const resolvedObjects = response.body.resolved_objects; + expect(resolvedObjects).length(testCaseArray.length); + for (let i = 0; i < resolvedObjects.length; i++) { + const resolvedObject = resolvedObjects[i]; + const testCase = testCaseArray[i]; + const { expectedId: id, expectedOutcome, expectedAliasTargetId } = testCase; + await expectResponses.permitted(resolvedObject.saved_object, { + ...testCase, + ...(!testCase.failure && id && { id }), // use expected ID instead of the requested ID iff the case was *not* a failure + }); + if (!testCase.failure) { + expect(resolvedObject.outcome).to.eql(expectedOutcome); + if (expectedOutcome === 'conflict' || expectedOutcome === 'aliasMatch') { + expect(resolvedObject.alias_target_id).to.eql(expectedAliasTargetId); + } else { + expect(resolvedObject.alias_target_id).to.eql(undefined); + } + // TODO: add assertions for redacted namespaces (#112455) + } + } + } + }; + const createTestDefinitions = ( + testCases: BulkResolveTestCase | BulkResolveTestCase[], + forbidden: boolean, + options?: { + spaceId?: string; + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): BulkResolveTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map((x) => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: options?.responseBodyOverride || expectResponseBody(x, responseStatusCode), + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map((x) => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || expectResponseBody(cases, responseStatusCode), + }, + ]; + }; + + const makeBulkResolveTest = (describeFn: Mocha.SuiteFunction) => ( + description: string, + definition: BulkResolveTestSuite + ) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => + esArchiver.load( + 'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces' + ) + ); + after(() => + esArchiver.unload( + 'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces' + ) + ); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_resolve`) + .auth(user?.username, user?.password) + .send(test.request) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeBulkResolveTest(describe); + // @ts-ignore + addTests.only = makeBulkResolveTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts index 7ac83b3be8d040..5737fea2a63d82 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts @@ -56,6 +56,7 @@ export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest await expectResponses.permitted(object, testCase); if (!testCase.failure) { expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + // TODO: add assertions for redacted namespaces (#112455) } } } diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts index 298e1a98071754..2f26015371f226 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -99,6 +99,7 @@ export function createTestSuiteFactory(esArchiver: any, supertest: SuperTest) expect(object.id).to.eql(expected.id); expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); expect(object.namespaces).to.eql(expectedNamespaces); + // TODO: improve assertions for redacted namespaces? (#112455) // don't test attributes, version, or references } } diff --git a/x-pack/test/saved_object_api_integration/common/suites/get.ts b/x-pack/test/saved_object_api_integration/common/suites/get.ts index a4d167276cc718..72c1457158db70 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/get.ts @@ -36,6 +36,7 @@ export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest) // permitted const object = response.body; await expectResponses.permitted(object, testCase); + // TODO: add assertions for redacted namespaces (#112455) } }; const createTestDefinitions = ( diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts index bfaeff7f366a49..d09bc09e48108c 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve.ts @@ -40,14 +40,14 @@ export const TEST_CASES = Object.freeze({ type: 'resolvetype', id: 'exact-match', expectedNamespaces: EACH_SPACE, - expectedOutcome: 'exactMatch' as 'exactMatch', + expectedOutcome: 'exactMatch' as const, expectedId: 'exact-match', }), ALIAS_MATCH: Object.freeze({ type: 'resolvetype', id: 'alias-match', expectedNamespaces: EACH_SPACE, - expectedOutcome: 'aliasMatch' as 'aliasMatch', + expectedOutcome: 'aliasMatch' as const, expectedId: 'alias-match-newid', expectedAliasTargetId: 'alias-match-newid', }), @@ -55,7 +55,7 @@ export const TEST_CASES = Object.freeze({ type: 'resolvetype', id: 'conflict', expectedNamespaces: EACH_SPACE, - expectedOutcome: 'conflict' as 'conflict', // only in space 1, where the alias exists + expectedOutcome: 'conflict' as const, // only in space 1, where the alias exists expectedId: 'conflict', expectedAliasTargetId: 'conflict-newid', }), @@ -89,6 +89,7 @@ export function resolveTestSuiteFactory(esArchiver: any, supertest: SuperTest { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.EXACT_MATCH, + { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, + { + ...CASES.CONFLICT, + ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }), + }, + { ...CASES.DISABLED, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = [...normalTypes, ...hiddenType]; + return { normalTypes, hiddenType, allTypes }; +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = bulkResolveTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; + + describe('_bulk_resolve', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: BulkResolveTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; + + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach((user) => { + _addTests(user, unauthorized); + }); + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + ].forEach((user) => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts index 0b9f057b71b375..5412f9d9bdfed4 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts @@ -22,6 +22,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_create')); loadTestFile(require.resolve('./bulk_get')); loadTestFile(require.resolve('./bulk_update')); + loadTestFile(require.resolve('./bulk_resolve')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./export')); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts index 26f8c3b484ee52..1e9adda1ddf953 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve.ts @@ -18,7 +18,7 @@ import { const { SPACE_1: { spaceId: SPACE_1_ID }, } = SPACES; -const { fail404 } = testCaseFailures; +const { fail400, fail404 } = testCaseFailures; const createTestCases = (spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect @@ -28,13 +28,13 @@ const createTestCases = (spaceId: string) => { { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.CONFLICT, - ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as 'exactMatch' }), + ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }), }, { ...CASES.DISABLED, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; - const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; - const allTypes = normalTypes.concat(hiddenType); + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = [...normalTypes, ...hiddenType]; return { normalTypes, hiddenType, allTypes }; }; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_resolve.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_resolve.ts new file mode 100644 index 00000000000000..6d91cf8eae67da --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_resolve.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + bulkResolveTestSuiteFactory, + TEST_CASES as CASES, + BulkResolveTestDefinition, +} from '../../common/suites/bulk_resolve'; + +const { fail400, fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.EXACT_MATCH }, + { ...CASES.ALIAS_MATCH, ...fail404() }, + { ...CASES.CONFLICT, expectedOutcome: 'exactMatch' as const }, + { ...CASES.DISABLED, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = [...normalTypes, ...hiddenType]; + return { normalTypes, hiddenType, allTypes }; +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = bulkResolveTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false, { singleRequest: true }), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; + + describe('_bulk_resolve', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: BulkResolveTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; + + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach((user) => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach((user) => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts index 2a7ea6f92f20f8..35fd8c6e0b3d92 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts @@ -21,6 +21,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_create')); loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./bulk_resolve')); loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts index 333f10d61d6495..fc4148a88c9791 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve.ts @@ -14,7 +14,7 @@ import { ResolveTestDefinition, } from '../../common/suites/resolve'; -const { fail404 } = testCaseFailures; +const { fail400, fail404 } = testCaseFailures; const createTestCases = () => { // for each permitted (non-403) outcome, if failure !== undefined then we expect @@ -22,12 +22,12 @@ const createTestCases = () => { const normalTypes = [ { ...CASES.EXACT_MATCH }, { ...CASES.ALIAS_MATCH, ...fail404() }, - { ...CASES.CONFLICT, expectedOutcome: 'exactMatch' as 'exactMatch' }, + { ...CASES.CONFLICT, expectedOutcome: 'exactMatch' as const }, { ...CASES.DISABLED, ...fail404() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ]; - const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; - const allTypes = normalTypes.concat(hiddenType); + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = [...normalTypes, ...hiddenType]; return { normalTypes, hiddenType, allTypes }; }; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_resolve.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_resolve.ts new file mode 100644 index 00000000000000..4f755e6165c751 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_resolve.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { bulkResolveTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_resolve'; + +const { + SPACE_1: { spaceId: SPACE_1_ID }, +} = SPACES; +const { fail400, fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => [ + // for each outcome, if failure !== undefined then we expect to receive + // an error; otherwise, we expect to receive a success result + CASES.EXACT_MATCH, + { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, + { + ...CASES.CONFLICT, + ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }), + }, + { ...CASES.DISABLED, ...fail404() }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, +]; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = bulkResolveTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { spaceId, singleRequest: true }); + }; + + describe('_bulk_resolve', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts index 302a7d3d1f8211..c6bdbde07fc02c 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts @@ -13,6 +13,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_create')); loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./bulk_resolve')); loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts index 0a54c033b4073d..21f23bf3f6d9b5 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve.ts @@ -13,7 +13,7 @@ import { resolveTestSuiteFactory, TEST_CASES as CASES } from '../../common/suite const { SPACE_1: { spaceId: SPACE_1_ID }, } = SPACES; -const { fail404 } = testCaseFailures; +const { fail400, fail404 } = testCaseFailures; const createTestCases = (spaceId: string) => [ // for each outcome, if failure !== undefined then we expect to receive @@ -22,10 +22,10 @@ const createTestCases = (spaceId: string) => [ { ...CASES.ALIAS_MATCH, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.CONFLICT, - ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as 'exactMatch' }), + ...(spaceId !== SPACE_1_ID && { expectedOutcome: 'exactMatch' as const }), }, { ...CASES.DISABLED, ...fail404() }, - { ...CASES.HIDDEN, ...fail404() }, + { ...CASES.HIDDEN, ...fail400() }, { ...CASES.DOES_NOT_EXIST, ...fail404() }, ];