From 2256c3214cb4af191a0c6cf82315a0ee1ec9f668 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Sun, 29 Nov 2020 15:48:56 -0500 Subject: [PATCH] WIP --- .../saved_objects/object_types/constants.ts | 23 ++ .../saved_objects/object_types/index.ts | 22 ++ .../object_types/registration.test.ts | 36 +++ .../object_types/registration.ts | 46 ++++ .../saved_objects/object_types/types.ts | 30 +++ src/core/server/saved_objects/routes/index.ts | 2 + .../server/saved_objects/routes/resolve.ts | 40 +++ .../saved_objects/saved_objects_service.ts | 3 + .../serialization/serializer.test.ts | 22 ++ .../saved_objects/serialization/serializer.ts | 12 + .../service/lib/repository.mock.ts | 1 + .../service/lib/repository.test.js | 250 ++++++++++++++++++ .../saved_objects/service/lib/repository.ts | 177 ++++++++++--- .../service/saved_objects_client.mock.ts | 1 + .../service/saved_objects_client.test.js | 16 ++ .../service/saved_objects_client.ts | 20 ++ ...ypted_saved_objects_client_wrapper.test.ts | 136 ++++++++++ .../encrypted_saved_objects_client_wrapper.ts | 16 ++ .../server/audit/audit_events.test.ts | 18 ++ .../security/server/audit/audit_events.ts | 3 + ...ecure_saved_objects_client_wrapper.test.ts | 77 ++++++ .../secure_saved_objects_client_wrapper.ts | 36 +++ .../spaces_saved_objects_client.test.ts | 31 +++ .../spaces_saved_objects_client.ts | 18 ++ 24 files changed, 1001 insertions(+), 35 deletions(-) create mode 100644 src/core/server/saved_objects/object_types/constants.ts create mode 100644 src/core/server/saved_objects/object_types/index.ts create mode 100644 src/core/server/saved_objects/object_types/registration.test.ts create mode 100644 src/core/server/saved_objects/object_types/registration.ts create mode 100644 src/core/server/saved_objects/object_types/types.ts create mode 100644 src/core/server/saved_objects/routes/resolve.ts diff --git a/src/core/server/saved_objects/object_types/constants.ts b/src/core/server/saved_objects/object_types/constants.ts new file mode 100644 index 000000000000000..246b38ee8e10a2e --- /dev/null +++ b/src/core/server/saved_objects/object_types/constants.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @internal + */ +export const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias'; diff --git a/src/core/server/saved_objects/object_types/index.ts b/src/core/server/saved_objects/object_types/index.ts new file mode 100644 index 000000000000000..d9a3e6252f4ef13 --- /dev/null +++ b/src/core/server/saved_objects/object_types/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { LEGACY_URL_ALIAS_TYPE } from './constants'; +export { LegacyUrlAlias } from './types'; +export { registerCoreObjectTypes } from './registration'; diff --git a/src/core/server/saved_objects/object_types/registration.test.ts b/src/core/server/saved_objects/object_types/registration.test.ts new file mode 100644 index 000000000000000..6a5daf1a357d5a4 --- /dev/null +++ b/src/core/server/saved_objects/object_types/registration.test.ts @@ -0,0 +1,36 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { typeRegistryMock } from '../saved_objects_type_registry.mock'; +import { LEGACY_URL_ALIAS_TYPE } from './constants'; +import { registerCoreObjectTypes } from './registration'; + +describe('Core saved object types registration', () => { + describe('#registerCoreObjectTypes', () => { + it('registers all expected types', () => { + const typeRegistry = typeRegistryMock.create(); + registerCoreObjectTypes(typeRegistry); + + expect(typeRegistry.registerType).toHaveBeenCalledTimes(1); + expect(typeRegistry.registerType).toHaveBeenCalledWith( + expect.objectContaining({ name: LEGACY_URL_ALIAS_TYPE }) + ); + }); + }); +}); diff --git a/src/core/server/saved_objects/object_types/registration.ts b/src/core/server/saved_objects/object_types/registration.ts new file mode 100644 index 000000000000000..5bb9981d88e65a3 --- /dev/null +++ b/src/core/server/saved_objects/object_types/registration.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LEGACY_URL_ALIAS_TYPE } from './constants'; +import { ISavedObjectTypeRegistry, SavedObjectTypeRegistry } from '..'; + +const legacyUrlAliasMappings = { + properties: { + targetNamespace: { type: 'keyword' }, + targetType: { type: 'keyword' }, + targetId: { type: 'keyword' }, + lastResolved: { type: 'date' }, + resolveCounter: { type: 'integer' }, + disabled: { type: 'boolean' }, + }, +}; + +/** + * @internal + */ +export function registerCoreObjectTypes( + typeRegistry: ISavedObjectTypeRegistry & Pick +) { + typeRegistry.registerType({ + name: LEGACY_URL_ALIAS_TYPE, + namespaceType: 'agnostic', + mappings: legacyUrlAliasMappings, + hidden: true, + }); +} diff --git a/src/core/server/saved_objects/object_types/types.ts b/src/core/server/saved_objects/object_types/types.ts new file mode 100644 index 000000000000000..8ba54168cac7828 --- /dev/null +++ b/src/core/server/saved_objects/object_types/types.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @internal + */ +export interface LegacyUrlAlias { + targetNamespace: string; + targetType: string; + targetId: string; + lastResolved?: string; + resolveCounter?: number; + disabled?: boolean; +} diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index fd57a9f3059e336..0e42414fb19e348 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -22,6 +22,7 @@ import { Logger } from '../../logging'; import { SavedObjectConfig } from '../saved_objects_config'; import { IKibanaMigrator } from '../migrations'; import { registerGetRoute } from './get'; +import { registerResolveRoute } from './resolve'; import { registerCreateRoute } from './create'; import { registerDeleteRoute } from './delete'; import { registerFindRoute } from './find'; @@ -49,6 +50,7 @@ export function registerRoutes({ const router = http.createRouter('/api/saved_objects/'); registerGetRoute(router); + registerResolveRoute(router); registerCreateRoute(router); registerDeleteRoute(router); registerFindRoute(router); diff --git a/src/core/server/saved_objects/routes/resolve.ts b/src/core/server/saved_objects/routes/resolve.ts new file mode 100644 index 000000000000000..2967273457dbc7a --- /dev/null +++ b/src/core/server/saved_objects/routes/resolve.ts @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '../../http'; + +export const registerResolveRoute = (router: IRouter) => { + router.get( + { + path: '/resolve/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const result = await context.core.savedObjects.client.resolve(type, id); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 5cc59d55a254ecf..70e91a1e8ec4967 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -53,6 +53,7 @@ import { registerRoutes } from './routes'; import { ServiceStatus } from '../status'; import { calculateStatus$ } from './status'; import { createMigrationEsClient } from './migrations/core/'; +import { registerCoreObjectTypes } from './object_types'; /** * Saved Objects is Kibana's data persistence mechanism allowing plugins to * use Elasticsearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods @@ -306,6 +307,8 @@ export class SavedObjectsService migratorPromise: this.migrator$.pipe(first()).toPromise(), }); + registerCoreObjectTypes(this.typeRegistry); + return { status$: calculateStatus$( this.migrator$.pipe(switchMap((migrator) => migrator.getStatus$())), diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 1107ad78a778277..84a26ba77ceb021 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -22,6 +22,7 @@ import { SavedObjectsSerializer } from './serializer'; import { SavedObjectsRawDoc } from './types'; import { typeRegistryMock } from '../saved_objects_type_registry.mock'; import { encodeVersion } from '../version'; +import { LEGACY_URL_ALIAS_TYPE } from '../object_types'; let typeRegistry = typeRegistryMock.create(); typeRegistry.isNamespaceAgnostic.mockReturnValue(true); @@ -1269,3 +1270,24 @@ describe('#generateRawId', () => { }); }); }); + +describe('#generateRawLegacyUrlAliasId', () => { + describe(`returns expected value`, () => { + const expected = `${LEGACY_URL_ALIAS_TYPE}:foo:bar:baz`; + + test(`for single-namespace types`, () => { + const id = singleNamespaceSerializer.generateRawLegacyUrlAliasId('foo', 'bar', 'baz'); + expect(id).toEqual(expected); + }); + + test(`for multi-namespace types`, () => { + const id = multiNamespaceSerializer.generateRawLegacyUrlAliasId('foo', 'bar', 'baz'); + expect(id).toEqual(expected); + }); + + test(`for namespace-agnostic types`, () => { + const id = namespaceAgnosticSerializer.generateRawLegacyUrlAliasId('foo', 'bar', 'baz'); + expect(id).toEqual(expected); + }); + }); +}); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index ba370686fd09c6a..1872a823f24e8f8 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -18,6 +18,7 @@ */ import uuid from 'uuid'; +import { LEGACY_URL_ALIAS_TYPE } from '../object_types'; import { decodeVersion, encodeVersion } from '../version'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { SavedObjectsRawDoc, SavedObjectSanitizedDoc, RawDocParseOptions } from './types'; @@ -150,6 +151,17 @@ export class SavedObjectsSerializer { return `${namespacePrefix}${type}:${id || uuid.v1()}`; } + /** + * Given a saved object type and id, generates the compound id that is stored in the raw document for its legacy URL alias. + * + * @param {string} namespace - The namespace of the saved object + * @param {string} type - The saved object type + * @param {string} id - The id of the saved object + */ + public generateRawLegacyUrlAliasId(namespace: string, type: string, id: string) { + return `${LEGACY_URL_ALIAS_TYPE}:${namespace}:${type}:${id}`; + } + private trimIdPrefix( namespace: string | undefined, type: string, 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 1b38a300debe6b7..0e8f345e5642cdb 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -28,6 +28,7 @@ const create = (): jest.Mocked => ({ bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), deleteFromNamespaces: 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 9c5d756a4f2530e..3805a873706d1a1 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -24,6 +24,7 @@ import { ALL_NAMESPACES_STRING } from './utils'; 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 { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { esKuery } from '../../es_query'; @@ -226,6 +227,7 @@ describe('SavedObjectsRepository', () => { rawToSavedObject: jest.fn(), savedObjectToRaw: jest.fn(), generateRawId: jest.fn(), + generateRawLegacyUrlAliasId: jest.fn(), trimIdPrefix: jest.fn(), }; const _serializer = new SavedObjectsSerializer(registry); @@ -3271,6 +3273,254 @@ 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: { + _source: { + [LEGACY_URL_ALIAS_TYPE]: { + targetId: aliasTargetId, + ...(resolveCounter && { resolveCounter }), + // other fields are not used by the repository + }, + }, + }, + }); + + describe('outcomes', () => { + describe('error', () => { + const expectNotFoundError = async (type, id, options) => { + await expect(savedObjectsRepository.resolve(type, id, options)).resolves.toEqual({ + saved_object: { type, id, error: createGenericNotFoundError(type, id) }, + outcome: 'error', + }); + }; + + it('because type is invalid', async () => { + await expectNotFoundError('unknownType', id); + expect(client.get).not.toHaveBeenCalled(); + expect(client.mget).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); + }); + + it('because type is hidden', async () => { + await expectNotFoundError(HIDDEN_TYPE, id); + expect(client.get).not.toHaveBeenCalled(); + expect(client.mget).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); + }); + + 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.get.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.get).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expect(client.update).toHaveBeenCalledTimes(0); + }); + }); + + 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.get).toHaveBeenCalledTimes(1); // retrieved actual target + expect(client.mget).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); + expect(result).toEqual({ + saved_object: expect.objectContaining({ type, id }), + outcome: 'exactMatch', + }); + }); + + describe('because alias is not used', () => { + const expectExactMatchResult = async (aliasResult) => { + const options = { namespace }; + client.get.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.get).toHaveBeenCalledTimes(2); // retrieved alias object and actual target + expect(client.mget).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); + 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: { found: false } }); + }); + + it('since alias is disabled', async () => { + await expectExactMatchResult({ + body: { _source: { [LEGACY_URL_ALIAS_TYPE]: { disabled: true } } }, + }); + }); + }); + + describe('because alias is used', () => { + const expectExactMatchResult = async (objectResults) => { + const options = { namespace }; + client.get.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.get).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expect(client.update).toHaveBeenCalledTimes(0); + 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_TYPE, id }, // correct namespace field is added by getMockMgetResponse + { type: MULTI_NAMESPACE_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.get.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.get).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expect(client.update).toHaveBeenCalledTimes(1); // updated alias object + expect(result).toEqual({ + saved_object: expect.objectContaining({ type, id: aliasTargetId }), + outcome: 'aliasMatch', + }); + }; + + it('because actual target is not found', async () => { + const objects = [ + { type, id, found: false }, + { type, id: aliasTargetId }, + ]; + await expectAliasMatchResult(objects); + }); + + it('because actual target does not exist in this namespace', async () => { + const objects = [ + { type: MULTI_NAMESPACE_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse + ]; + await expectAliasMatchResult(objects); + }); + }); + + 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.get.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.get).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expect(client.update).toHaveBeenCalledTimes(1); // updated alias object + expect(result).toEqual({ + saved_object: expect.objectContaining({ type, id }), + outcome: 'conflict', + }); + }); + }); + }); + + describe('alias object update', () => { + const objectResults = [ + { type, id, found: false }, + { type, id: aliasTargetId }, + ]; + + it('when resolveCounter is undefined', async () => { + const options = { namespace }; + client.get.mockResolvedValueOnce(getMockAliasDocument()); // for alias object + const response = getMockMgetResponse(objectResults, options.namespace); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target + ); + + await savedObjectsRepository.resolve(type, id, options); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ resolveCounter: 1 }) }), + expect.anything() + ); + }); + + it('when resolveCounter is defined', async () => { + const options = { namespace }; + client.get.mockResolvedValueOnce(getMockAliasDocument(42)); // for alias object + const response = getMockMgetResponse(objectResults, options.namespace); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) // for actual target and alias target + ); + + await savedObjectsRepository.resolve(type, id, options); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.objectContaining({ resolveCounter: 43 }) }), + expect.anything() + ); + }); + }); + }); + describe('#incrementCounter', () => { const type = 'config'; const id = 'one'; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 2a21bb95b2f0696..938ff13818c2375 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -59,6 +59,7 @@ import { SavedObjectsDeleteFromNamespacesResponse, SavedObjectsRemoveReferencesToOptions, SavedObjectsRemoveReferencesToResponse, + SavedObjectsResolveResponse, } from '../saved_objects_client'; import { SavedObject, @@ -67,6 +68,7 @@ import { SavedObjectsMigrationVersion, MutatingOperationRefreshSetting, } from '../../types'; +import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; import { @@ -911,25 +913,7 @@ export class SavedObjectsRepository { } as any) as SavedObject; } - const { originId, updated_at: updatedAt } = doc._source; - let namespaces = []; - if (!this._registry.isNamespaceAgnostic(type)) { - namespaces = doc._source.namespaces ?? [ - SavedObjectsUtils.namespaceIdToString(doc._source.namespace), - ]; - } - - return { - id, - type, - namespaces, - ...(originId && { originId }), - ...(updatedAt && { updated_at: updatedAt }), - version: encodeHitVersion(doc), - attributes: doc._source[type], - references: doc._source.references || [], - migrationVersion: doc._source.migrationVersion, - }; + return this.getSavedObjectFromSource(type, id, doc); }), }; } @@ -969,26 +953,113 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId, updated_at: updatedAt } = body._source; + return this.getSavedObjectFromSource(type, id, body); + } - let namespaces: string[] = []; - if (!this._registry.isNamespaceAgnostic(type)) { - namespaces = body._source.namespaces ?? [ - SavedObjectsUtils.namespaceIdToString(body._source.namespace), - ]; + /** + * Resolves a single object, using any legacy URL alias if it exists + * + * @param {string} type + * @param {string} id + * @param {object} [options={}] + * @property {string} [options.namespace] + * @returns {promise} - { saved_object, outcome } + */ + async resolve( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> { + const notFoundError = { + saved_object: ({ + type, + id, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + } as any) as SavedObject, + outcome: 'error' as 'error', + }; + if (!this._allowedTypes.includes(type)) { + return notFoundError; } - return { - id, - type, - namespaces, - ...(originId && { originId }), - ...(updatedAt && { updated_at: updatedAt }), - version: encodeHitVersion(body), - attributes: body._source[type], - references: body._source.references || [], - migrationVersion: body._source.migrationVersion, + 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 aliasResponse = await this.client.get>( + { id: rawAliasId, index: this.getIndexForType(LEGACY_URL_ALIAS_TYPE) }, + { ignore: [404] } + ); + + if ( + aliasResponse.statusCode === 404 || + aliasResponse.body.found === false || + aliasResponse.body._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._source[LEGACY_URL_ALIAS_TYPE]; + const aliasUpdateParams = { + id: rawAliasId, + index: this.getIndexForType(LEGACY_URL_ALIAS_TYPE), + body: { + ...legacyUrlAlias, + lastResolved: new Date().toISOString(), + resolveCounter: (legacyUrlAlias.resolveCounter ?? 0) + 1, + }, }; + + 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] } + ); + + const exactMatchDoc = bulkGetResponse?.body.docs[0]; + const aliasMatchDoc = bulkGetResponse?.body.docs[1]; + const foundExactMatch = + exactMatchDoc.found && this.rawDocExistsInNamespace(exactMatchDoc, namespace); + const foundAliasMatch = + aliasMatchDoc.found && this.rawDocExistsInNamespace(aliasMatchDoc, namespace); + + if (foundExactMatch && foundAliasMatch) { + this.client.update(aliasUpdateParams).catch(); // if an error occurs when updating the legacy URL alias, swallow it + return { + saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), + outcome: 'conflict', + }; + } else if (foundExactMatch) { + return { + saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), + outcome: 'exactMatch', + }; + } else if (foundAliasMatch) { + this.client.update(aliasUpdateParams).catch(); // if an error occurs when updating the legacy URL alias, swallow it + return { + saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc), + outcome: 'aliasMatch', + }; + } + return notFoundError; } /** @@ -1733,6 +1804,42 @@ export class SavedObjectsRepository { } return body as SavedObjectsRawDoc; } + + private getSavedObjectFromSource( + type: string, + id: string, + doc: { _seq_no: number; _primary_term: number; _source: SavedObjectsRawDocSource } + ): SavedObject { + const { originId, updated_at: updatedAt } = doc._source; + + let namespaces: string[] = []; + if (!this._registry.isNamespaceAgnostic(type)) { + namespaces = doc._source.namespaces ?? [ + SavedObjectsUtils.namespaceIdToString(doc._source.namespace), + ]; + } + + return { + id, + type, + namespaces, + ...(originId && { originId }), + ...(updatedAt && { updated_at: updatedAt }), + version: encodeHitVersion(doc), + attributes: doc._source[type], + references: doc._source.references || [], + migrationVersion: doc._source.migrationVersion, + }; + } + + private async resolveExactMatch( + type: string, + id: string, + options: SavedObjectsBaseOptions + ): Promise> { + const object = await this.get(type, id, options); + return { saved_object: object, outcome: object.error ? 'error' : 'exactMatch' }; + } } function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) { 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 7b300129f0b9a2c..4609f60785a5d5e 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 @@ -31,6 +31,7 @@ const create = () => bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + resolve: jest.fn(), update: jest.fn(), addToNamespaces: jest.fn(), deleteFromNamespaces: 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 3298121f9571f70..65f0d6bcbdd1a25 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 @@ -126,6 +126,22 @@ test(`#get`, async () => { expect(result).toBe(returnValue); }); +test(`#resolve`, async () => { + const returnValue = Symbol(); + const mockRepository = { + resolve: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const id = Symbol(); + const options = Symbol(); + const result = await client.resolve(type, id, options); + + expect(mockRepository.resolve).toHaveBeenCalledWith(type, id, options); + expect(result).toBe(returnValue); +}); + test(`#update`, 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 6cb9823c736e0ac..8e98c641356f2d8 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -284,6 +284,11 @@ export interface SavedObjectsUpdateResponse references: SavedObjectReference[] | undefined; } +export interface SavedObjectsResolveResponse { + saved_object: SavedObject; + outcome: 'exactMatch' | 'aliasMatch' | 'conflict' | 'error'; +} + /** * * @public @@ -390,6 +395,21 @@ export class SavedObjectsClient { return await this._repository.get(type, id, options); } + /** + * 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 options + */ + async resolve( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> { + return await this._repository.resolve(type, id, options); + } + /** * Updates an SavedObject * 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 3c722ccfabae24b..42a818473768141 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 @@ -1500,6 +1500,142 @@ describe('#get', () => { }); }); +describe('#resolve', () => { + it('redirects request to underlying base client and does not alter response if outcome is an error', async () => { + const mockedResponse = { + saved_object: { + id: 'some-id', + type: 'unknown-type', + // a real error outcome would not include attributes or references fields, but we include these for type safety + attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + references: [], + }, + outcome: 'error' as 'error', + }; + + mockBaseClient.resolve.mockResolvedValue(mockedResponse); + + const options = { namespace: 'some-ns' }; + await expect(wrapper.resolve('unknown-type', 'some-id', options)).resolves.toEqual( + mockedResponse + ); + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('unknown-type', 'some-id', 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 = { + saved_object: { + id: 'some-id', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references: [], + }, + outcome: 'exactMatch' as 'exactMatch', + }; + + mockBaseClient.resolve.mockResolvedValue(mockedResponse); + + const options = { namespace: 'some-ns' }; + await expect(wrapper.resolve('known-type', 'some-id', options)).resolves.toEqual({ + ...mockedResponse, + saved_object: { + ...mockedResponse.saved_object, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, + }, + }); + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); + }); + + it('includes both attributes and error with modified outcome if decryption fails.', async () => { + const mockedResponse = { + saved_object: { + id: 'some-id', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references: [], + }, + outcome: 'exactMatch' as 'exactMatch', + }; + + mockBaseClient.resolve.mockResolvedValue(mockedResponse); + + const decryptionError = new EncryptionError( + 'something failed', + 'attrNotSoSecret', + EncryptionErrorOperation.Decryption + ); + encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }); + + const options = { namespace: 'some-ns' }; + await expect(wrapper.resolve('known-type', 'some-id', options)).resolves.toEqual({ + ...mockedResponse, + saved_object: { + ...mockedResponse.saved_object, + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }, + outcome: 'error', + }); + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id', 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.resolve.mockRejectedValue(failureReason); + + await expect(wrapper.resolve('known-type', 'some-id')).rejects.toThrowError(failureReason); + + expect(mockBaseClient.resolve).toHaveBeenCalledTimes(1); + expect(mockBaseClient.resolve).toHaveBeenCalledWith('known-type', 'some-id', undefined); + }); +}); + describe('#update', () => { it('redirects request to underlying base client if type is not registered', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; 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 ddef9f477433caf..d28e347d1846fa9 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 @@ -213,6 +213,22 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon ); } + public async resolve(type: string, id: string, options?: SavedObjectsBaseOptions) { + const resolveResult = await this.options.baseClient.resolve(type, id, options); + if (resolveResult.outcome === 'error') { + return resolveResult; + } + const object = await this.handleEncryptedAttributesInResponse( + resolveResult.saved_object, + undefined as unknown, + getDescriptorNamespace(this.options.baseTypeRegistry, type, options?.namespace) + ); + return { + saved_object: object, + outcome: object.error ? ('error' as 'error') : resolveResult.outcome, + }; + } + public async update( type: string, id: string, diff --git a/x-pack/plugins/security/server/audit/audit_events.test.ts b/x-pack/plugins/security/server/audit/audit_events.test.ts index c826bb1d33f9994..1b6adb814b2902b 100644 --- a/x-pack/plugins/security/server/audit/audit_events.test.ts +++ b/x-pack/plugins/security/server/audit/audit_events.test.ts @@ -113,6 +113,12 @@ describe('#savedObjectEvent', () => { savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, }) ).not.toBeUndefined(); + expect( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type: 'dashboard', id: 'SAVED_OBJECT_ID' }, + }) + ).not.toBeUndefined(); expect( savedObjectEvent({ action: SavedObjectAction.FIND, @@ -134,6 +140,18 @@ describe('#savedObjectEvent', () => { savedObject: { type: 'telemetry', id: 'SAVED_OBJECT_ID' }, }) ).toBeUndefined(); + expect( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type: 'config', id: 'SAVED_OBJECT_ID' }, + }) + ).toBeUndefined(); + expect( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type: 'telemetry', id: 'SAVED_OBJECT_ID' }, + }) + ).toBeUndefined(); expect( savedObjectEvent({ action: SavedObjectAction.FIND, diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 6aba78c93607106..1973f1adf971fa8 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -170,6 +170,7 @@ export function userLoginEvent({ export enum SavedObjectAction { CREATE = 'saved_object_create', GET = 'saved_object_get', + RESOLVE = 'saved_object_resolve', UPDATE = 'saved_object_update', DELETE = 'saved_object_delete', FIND = 'saved_object_find', @@ -181,6 +182,7 @@ export enum SavedObjectAction { const eventVerbs = { saved_object_create: ['create', 'creating', 'created'], saved_object_get: ['access', 'accessing', 'accessed'], + saved_object_resolve: ['resolve', 'resolving', 'resolved'], saved_object_update: ['update', 'updating', 'updated'], saved_object_delete: ['delete', 'deleting', 'deleted'], saved_object_find: ['access', 'accessing', 'accessed'], @@ -196,6 +198,7 @@ const eventVerbs = { const eventTypes = { saved_object_create: EventType.CREATION, saved_object_get: EventType.ACCESS, + saved_object_resolve: EventType.ACCESS, saved_object_update: EventType.CHANGE, saved_object_delete: EventType.DELETION, saved_object_find: EventType.ACCESS, 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 c6f4ca6dd8afe6d..61fcdbacb289ae1 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 @@ -163,6 +163,7 @@ const expectObjectNamespaceFiltering = async ( // we don't know which base client method will be called; mock them all clientOpts.baseClient.create.mockReturnValue(returnValue as any); clientOpts.baseClient.get.mockReturnValue(returnValue as any); + // 'resolve' is excluded because it has a specific test case written for it clientOpts.baseClient.update.mockReturnValue(returnValue as any); clientOpts.baseClient.addToNamespaces.mockReturnValue(returnValue as any); clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(returnValue as any); @@ -969,6 +970,82 @@ describe('#get', () => { }); }); +describe('#resolve', () => { + const type = 'foo'; + const id = `${type}-id`; + const namespace = 'some-ns'; + const resolvedId = 'another-id'; // success audit records include the resolved ID, not the requested ID + const mockResult = { saved_object: { id: resolvedId } }; // mock result needs to have ID for audit logging + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.resolve, { type, id }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; + await expectForbiddenError(client.resolve, { type, id, options }, 'resolve'); + }); + + test(`returns result of baseClient.resolve when authorized`, async () => { + const apiCallReturnValue = mockResult; + clientOpts.baseClient.resolve.mockReturnValue(apiCallReturnValue as any); + + const options = { namespace }; + const result = await expectSuccess(client.resolve, { type, id, options }, 'resolve'); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const options = { namespace }; + await expectPrivilegeCheck(client.resolve, { type, id, options }, namespace); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const options = { namespace }; + + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // privilege check for authorization + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // privilege check for namespace filtering + ); + + const namespaces = ['some-other-namespace', '*', namespace]; + const returnValue = { saved_object: { namespaces, id: resolvedId, foo: 'bar' } }; + clientOpts.baseClient.resolve.mockReturnValue(returnValue as any); + + const result = await client.resolve(type, id, options); + // we will never redact the "All Spaces" ID + expect(result).toEqual({ + saved_object: expect.objectContaining({ 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 authorizedNamespace either, as that was already checked earlier in the operation + ); + }); + + test(`adds audit event when successful`, async () => { + const apiCallReturnValue = mockResult; + clientOpts.baseClient.resolve.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; + await expectSuccess(client.resolve, { type, id, options }, 'resolve'); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_resolve', EventOutcome.SUCCESS, { type, id: resolvedId }); + }); + + test(`adds audit event when not successful`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); + await expect(() => client.resolve(type, id, { namespace })).rejects.toThrow(); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); + expectAuditEvent('saved_object_resolve', EventOutcome.FAILURE, { type, id }); + }); +}); + describe('#deleteFromNamespaces', () => { const type = 'foo'; const id = `${type}-id`; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index e6e34de4ac9abfc..1919317a1048ea6 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 @@ -323,6 +323,42 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]); } + public async resolve( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ) { + try { + const args = { type, id, options }; + await this.ensureAuthorized(type, 'get', options.namespace, { args, auditAction: 'resolve' }); + } catch (error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type, id }, + error, + }) + ); + throw error; + } + + const resolveResult = await this.baseClient.resolve(type, id, options); + + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: { type, id: resolveResult.saved_object.id }, + }) + ); + + return { + ...resolveResult, + saved_object: await this.redactSavedObjectNamespaces(resolveResult.saved_object, [ + options.namespace, + ]), + }; + } + public async update( type: string, id: string, 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 4fd952950733541..a79651c1ae9a615 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 @@ -103,6 +103,37 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); + describe('#resolve', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.resolve('foo', '', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { + saved_object: createMockResponse(), + outcome: 'exactMatch' as 'exactMatch', // outcome doesn't matter, just including it for type safety + }; + baseClient.resolve.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const type = Symbol(); + const id = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.resolve(type, id, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.resolve).toHaveBeenCalledWith(type, id, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + describe('#bulkGet', () => { 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 049bd88085ed5cc..3219017a83bda2a 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 @@ -246,6 +246,24 @@ 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 } + */ + 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 *