From b2d36b863bc945479a12022a7a2cc4902ad2bd45 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 14 May 2021 14:46:17 -0400 Subject: [PATCH] Sharing saved objects phase 3 (#94383) --- api_docs/spaces.json | 2 +- .../core/public/kibana-plugin-core-public.md | 2 + ...blic.savedobjectreferencewithcontext.id.md | 13 + ...treferencewithcontext.inboundreferences.md | 17 + ...vedobjectreferencewithcontext.ismissing.md | 13 + ...-public.savedobjectreferencewithcontext.md | 25 + ....savedobjectreferencewithcontext.spaces.md | 13 + ...cewithcontext.spaceswithmatchingaliases.md | 13 + ...ic.savedobjectreferencewithcontext.type.md | 13 + ...collectmultinamespacereferencesresponse.md | 20 + ...ultinamespacereferencesresponse.objects.md | 11 + ...ver.isavedobjectspointintimefinder.find.md | 2 +- ...e-server.isavedobjectspointintimefinder.md | 4 +- .../core/server/kibana-plugin-core-server.md | 12 +- ...jectexportbaseoptions.includenamespaces.md | 13 + ...ore-server.savedobjectexportbaseoptions.md | 1 + ...rver.savedobjectreferencewithcontext.id.md | 13 + ...treferencewithcontext.inboundreferences.md | 17 + ...vedobjectreferencewithcontext.ismissing.md | 13 + ...-server.savedobjectreferencewithcontext.md | 25 + ....savedobjectreferencewithcontext.spaces.md | 13 + ...cewithcontext.spaceswithmatchingaliases.md | 13 + ...er.savedobjectreferencewithcontext.type.md | 13 + ...rver.savedobjectsaddtonamespacesoptions.md | 20 - ...edobjectsaddtonamespacesoptions.refresh.md | 13 - ...edobjectsaddtonamespacesoptions.version.md | 13 - ...ver.savedobjectsaddtonamespacesresponse.md | 19 - ...jectsaddtonamespacesresponse.namespaces.md | 13 - ...rver.savedobjectsclient.addtonamespaces.md | 27 - ...sclient.collectmultinamespacereferences.md | 25 + ...edobjectsclient.createpointintimefinder.md | 4 +- ...savedobjectsclient.deletefromnamespaces.md | 27 - ...a-plugin-core-server.savedobjectsclient.md | 4 +- ....savedobjectsclient.updateobjectsspaces.md | 27 + ...ollectmultinamespacereferencesobject.id.md | 11 + ...tscollectmultinamespacereferencesobject.md | 23 + ...lectmultinamespacereferencesobject.type.md | 11 + ...scollectmultinamespacereferencesoptions.md | 20 + ...multinamespacereferencesoptions.purpose.md | 13 + ...collectmultinamespacereferencesresponse.md | 20 + ...ultinamespacereferencesresponse.objects.md | 11 + ...savedobjectsdeletefromnamespacesoptions.md | 19 - ...ectsdeletefromnamespacesoptions.refresh.md | 13 - ...avedobjectsdeletefromnamespacesresponse.md | 19 - ...deletefromnamespacesresponse.namespaces.md | 13 - ....savedobjectsrepository.addtonamespaces.md | 27 - ...ository.collectmultinamespacereferences.md | 25 + ...jectsrepository.createpointintimefinder.md | 4 +- ...dobjectsrepository.deletefromnamespaces.md | 27 - ...ugin-core-server.savedobjectsrepository.md | 4 +- ...edobjectsrepository.updateobjectsspaces.md | 27 + ...savedobjectsserializer.rawtosavedobject.md | 4 +- ...avedobjectsupdateobjectsspacesobject.id.md | 13 + ...r.savedobjectsupdateobjectsspacesobject.md | 21 + ...edobjectsupdateobjectsspacesobject.type.md | 13 + ....savedobjectsupdateobjectsspacesoptions.md | 20 + ...jectsupdateobjectsspacesoptions.refresh.md | 13 + ...savedobjectsupdateobjectsspacesresponse.md | 20 + ...ectsupdateobjectsspacesresponse.objects.md | 11 + ...updateobjectsspacesresponseobject.error.md | 13 + ...ctsupdateobjectsspacesresponseobject.id.md | 13 + ...bjectsupdateobjectsspacesresponseobject.md | 23 + ...pdateobjectsspacesresponseobject.spaces.md | 13 + ...supdateobjectsspacesresponseobject.type.md | 13 + ...rver.indexpatternsserviceprovider.start.md | 4 +- ...plugin-plugins-data-server.plugin.start.md | 4 +- src/core/public/index.ts | 2 + src/core/public/public.api.md | 20 + src/core/public/saved_objects/index.ts | 2 + src/core/server/index.ts | 12 +- .../export/saved_objects_exporter.test.ts | 23 + .../export/saved_objects_exporter.ts | 9 +- src/core/server/saved_objects/export/types.ts | 6 + .../migrations/core/document_migrator.test.ts | 4 + .../migrations/core/document_migrator.ts | 1 + .../integration_tests/rewriting_id.test.ts | 2 + .../object_types/registration.ts | 11 +- .../saved_objects/object_types/types.ts | 36 + .../saved_objects/serialization/serializer.ts | 4 +- .../server/saved_objects/service/index.ts | 8 + ...ct_multi_namespace_references.test.mock.ts | 21 + ...collect_multi_namespace_references.test.ts | 444 ++++++++++ .../lib/collect_multi_namespace_references.ts | 310 +++++++ .../service/lib/included_fields.test.ts | 136 +-- .../service/lib/included_fields.ts | 25 +- .../server/saved_objects/service/lib/index.ts | 14 + .../service/lib/internal_utils.test.ts | 243 ++++++ .../service/lib/internal_utils.ts | 143 +++ .../service/lib/point_in_time_finder.ts | 9 +- .../service/lib/repository.mock.ts | 4 +- .../service/lib/repository.test.js | 660 +++----------- .../service/lib/repository.test.mock.ts | 30 + .../saved_objects/service/lib/repository.ts | 368 ++------ .../lib/update_objects_spaces.test.mock.ts | 29 + .../service/lib/update_objects_spaces.test.ts | 453 ++++++++++ .../service/lib/update_objects_spaces.ts | 315 +++++++ .../service/saved_objects_client.mock.ts | 4 +- .../service/saved_objects_client.test.js | 49 +- .../service/saved_objects_client.ts | 116 +-- src/core/server/server.api.md | 100 ++- src/core/server/types.ts | 4 + src/plugins/data/server/server.api.md | 4 +- src/plugins/spaces_oss/public/api.ts | 4 +- .../apis/saved_objects/migrations.ts | 2 + ...ypted_saved_objects_client_wrapper.test.ts | 76 ++ .../encrypted_saved_objects_client_wrapper.ts | 50 +- .../job_spaces_list/job_spaces_list.tsx | 18 +- .../services/ml_api_service/saved_objects.ts | 19 +- .../models/data_recognizer/data_recognizer.ts | 5 +- x-pack/plugins/ml/server/routes/apidoc.json | 3 +- .../plugins/ml/server/routes/saved_objects.ts | 60 +- .../ml/server/routes/schemas/saved_objects.ts | 3 +- .../ml/server/saved_objects/service.ts | 74 +- .../security/server/audit/audit_events.ts | 14 + .../saved_objects/ensure_authorized.test.ts | 226 +++++ .../server/saved_objects/ensure_authorized.ts | 165 ++++ ...saved_objects_client_wrapper.test.mocks.ts | 17 + ...ecure_saved_objects_client_wrapper.test.ts | 816 +++++++++++++----- .../secure_saved_objects_client_wrapper.ts | 468 +++++++--- x-pack/plugins/spaces/common/index.ts | 2 +- .../share_to_space_flyout_internal.test.tsx | 62 +- .../share_to_space_flyout_internal.tsx | 85 +- .../spaces_manager/spaces_manager.mock.ts | 4 +- .../public/spaces_manager/spaces_manager.ts | 23 +- .../lib/copy_to_spaces/copy_to_spaces.test.ts | 159 +++- .../lib/copy_to_spaces/copy_to_spaces.ts | 21 +- .../copy_to_spaces/resolve_copy_conflicts.ts | 4 + .../external/get_shareable_references.test.ts | 144 ++++ .../api/external/get_shareable_references.ts | 42 + .../server/routes/api/external/index.ts | 6 +- .../api/external/share_to_space.test.ts | 252 ------ .../routes/api/external/share_to_space.ts | 77 -- .../external/update_objects_spaces.test.ts | 176 ++++ .../api/external/update_objects_spaces.ts | 70 ++ .../spaces_saved_objects_client.test.ts | 125 +-- .../spaces_saved_objects_client.ts | 87 +- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - .../apis/ml/saved_objects/can_delete_job.ts | 2 +- .../apis/ml/saved_objects/index.ts | 3 +- .../ml/saved_objects/remove_job_from_space.ts | 104 --- ..._job_to_space.ts => update_jobs_spaces.ts} | 27 +- x-pack/test/functional/services/ml/api.ts | 17 +- .../saved_objects/spaces/data.json | 3 + .../saved_objects/spaces/data.json | 70 ++ .../common/suites/copy_to_space.ts | 114 ++- .../common/suites/get_shareable_references.ts | 270 ++++++ .../common/suites/share_add.ts | 110 --- .../common/suites/share_remove.ts | 109 --- .../common/suites/update_objects_spaces.ts | 142 +++ .../apis/get_shareable_references.ts | 86 ++ .../security_and_spaces/apis/index.ts | 4 +- .../security_and_spaces/apis/share_add.ts | 144 ---- .../security_and_spaces/apis/share_remove.ts | 104 --- .../apis/update_objects_spaces.ts | 170 ++++ .../apis/get_shareable_references.ts | 62 ++ .../spaces_only/apis/index.ts | 4 +- .../spaces_only/apis/share_add.ts | 100 --- .../spaces_only/apis/share_remove.ts | 110 --- .../spaces_only/apis/update_objects_spaces.ts | 143 +++ 160 files changed, 6574 insertions(+), 3270 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.id.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.ismissing.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaces.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingaliases.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.type.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.md create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.objects.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectexportbaseoptions.includenamespaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.ismissing.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingaliases.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.type.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.namespaces.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.collectmultinamespacereferences.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.updateobjectsspaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.type.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.purpose.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.objects.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.namespaces.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.collectmultinamespacereferences.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.updateobjectsspaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.type.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.refresh.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.objects.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.error.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.id.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.spaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.type.md create mode 100644 src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.mock.ts create mode 100644 src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.ts create mode 100644 src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts create mode 100644 src/core/server/saved_objects/service/lib/internal_utils.test.ts create mode 100644 src/core/server/saved_objects/service/lib/internal_utils.ts create mode 100644 src/core/server/saved_objects/service/lib/update_objects_spaces.test.mock.ts create mode 100644 src/core/server/saved_objects/service/lib/update_objects_spaces.test.ts create mode 100644 src/core/server/saved_objects/service/lib/update_objects_spaces.ts create mode 100644 x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts create mode 100644 x-pack/plugins/security/server/saved_objects/ensure_authorized.ts create mode 100644 x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.mocks.ts create mode 100644 x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.test.ts create mode 100644 x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.ts delete mode 100644 x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts delete mode 100644 x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts create mode 100644 x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.test.ts create mode 100644 x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.ts delete mode 100644 x-pack/test/api_integration/apis/ml/saved_objects/remove_job_from_space.ts rename x-pack/test/api_integration/apis/ml/saved_objects/{assign_job_to_space.ts => update_jobs_spaces.ts} (85%) create mode 100644 x-pack/test/spaces_api_integration/common/suites/get_shareable_references.ts delete mode 100644 x-pack/test/spaces_api_integration/common/suites/share_add.ts delete mode 100644 x-pack/test/spaces_api_integration/common/suites/share_remove.ts create mode 100644 x-pack/test/spaces_api_integration/common/suites/update_objects_spaces.ts create mode 100644 x-pack/test/spaces_api_integration/security_and_spaces/apis/get_shareable_references.ts delete mode 100644 x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts delete mode 100644 x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts create mode 100644 x-pack/test/spaces_api_integration/security_and_spaces/apis/update_objects_spaces.ts create mode 100644 x-pack/test/spaces_api_integration/spaces_only/apis/get_shareable_references.ts delete mode 100644 x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts delete mode 100644 x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts create mode 100644 x-pack/test/spaces_api_integration/spaces_only/apis/update_objects_spaces.ts diff --git a/api_docs/spaces.json b/api_docs/spaces.json index d53b69d5bd6b5..940bbcf88a484 100644 --- a/api_docs/spaces.json +++ b/api_docs/spaces.json @@ -1867,7 +1867,7 @@ "section": "def-server.SavedObjectsRepository", "text": "SavedObjectsRepository" }, - ", \"get\" | \"delete\" | \"create\" | \"bulkCreate\" | \"checkConflicts\" | \"deleteByNamespace\" | \"find\" | \"bulkGet\" | \"resolve\" | \"update\" | \"addToNamespaces\" | \"deleteFromNamespaces\" | \"bulkUpdate\" | \"removeReferencesTo\" | \"incrementCounter\" | \"openPointInTimeForType\" | \"closePointInTime\" | \"createPointInTimeFinder\">" + ", \"get\" | \"delete\" | \"create\" | \"bulkCreate\" | \"checkConflicts\" | \"deleteByNamespace\" | \"find\" | \"bulkGet\" | \"resolve\" | \"update\" | \"collectMultiNamespaceReferences\" | \"updateObjectsSpaces\" | \"bulkUpdate\" | \"removeReferencesTo\" | \"incrementCounter\" | \"openPointInTimeForType\" | \"closePointInTime\" | \"createPointInTimeFinder\">" ], "source": { "path": "x-pack/plugins/spaces/server/spaces_client/spaces_client_service.ts", diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index b868a7f8216df..5280d85f3d3b3 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -103,12 +103,14 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttributes](./kibana-plugin-core-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | | [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) | | | [SavedObjectReference](./kibana-plugin-core-public.savedobjectreference.md) | A reference to another saved object. | +| [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) | A returned input object or one of its references, with additional context. | | [SavedObjectsBaseOptions](./kibana-plugin-core-public.savedobjectsbaseoptions.md) | | | [SavedObjectsBatchResponse](./kibana-plugin-core-public.savedobjectsbatchresponse.md) | | | [SavedObjectsBulkCreateObject](./kibana-plugin-core-public.savedobjectsbulkcreateobject.md) | | | [SavedObjectsBulkCreateOptions](./kibana-plugin-core-public.savedobjectsbulkcreateoptions.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. | | [SavedObjectsCreateOptions](./kibana-plugin-core-public.savedobjectscreateoptions.md) | | | [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) | | | [SavedObjectsFindOptionsReference](./kibana-plugin-core-public.savedobjectsfindoptionsreference.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.id.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.id.md new file mode 100644 index 0000000000000..10e01d7e7a931 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.id.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) > [id](./kibana-plugin-core-public.savedobjectreferencewithcontext.id.md) + +## SavedObjectReferenceWithContext.id property + +The ID of the referenced object + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md new file mode 100644 index 0000000000000..722b11f0c7ba9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) > [inboundReferences](./kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md) + +## SavedObjectReferenceWithContext.inboundReferences property + +References to this object; note that this does not contain \_all inbound references everywhere for this object\_, it only contains inbound references for the scope of this operation + +Signature: + +```typescript +inboundReferences: Array<{ + type: string; + id: string; + name: string; + }>; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.ismissing.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.ismissing.md new file mode 100644 index 0000000000000..8a4b378850764 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.ismissing.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) > [isMissing](./kibana-plugin-core-public.savedobjectreferencewithcontext.ismissing.md) + +## SavedObjectReferenceWithContext.isMissing property + +Whether or not this object or reference is missing + +Signature: + +```typescript +isMissing?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md new file mode 100644 index 0000000000000..a79fa96695e36 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) + +## SavedObjectReferenceWithContext interface + +A returned input object or one of its references, with additional context. + +Signature: + +```typescript +export interface SavedObjectReferenceWithContext +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-public.savedobjectreferencewithcontext.id.md) | string | The ID of the referenced object | +| [inboundReferences](./kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md) | Array<{
type: string;
id: string;
name: string;
}> | References to this object; note that this does not contain \_all inbound references everywhere for this object\_, it only contains inbound references for the scope of this operation | +| [isMissing](./kibana-plugin-core-public.savedobjectreferencewithcontext.ismissing.md) | boolean | Whether or not this object or reference is missing | +| [spaces](./kibana-plugin-core-public.savedobjectreferencewithcontext.spaces.md) | string[] | The space(s) that the referenced object exists in | +| [spacesWithMatchingAliases](./kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingaliases.md) | string[] | The space(s) that legacy URL aliases matching this type/id exist in | +| [type](./kibana-plugin-core-public.savedobjectreferencewithcontext.type.md) | string | The type of the referenced object | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaces.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaces.md new file mode 100644 index 0000000000000..9140e94721f1e --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaces.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) > [spaces](./kibana-plugin-core-public.savedobjectreferencewithcontext.spaces.md) + +## SavedObjectReferenceWithContext.spaces property + +The space(s) that the referenced object exists in + +Signature: + +```typescript +spaces: string[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingaliases.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingaliases.md new file mode 100644 index 0000000000000..02b0c9c0949df --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingaliases.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) > [spacesWithMatchingAliases](./kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingaliases.md) + +## SavedObjectReferenceWithContext.spacesWithMatchingAliases property + +The space(s) that legacy URL aliases matching this type/id exist in + +Signature: + +```typescript +spacesWithMatchingAliases?: string[]; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.type.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.type.md new file mode 100644 index 0000000000000..d2e341627153c --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.type.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) > [type](./kibana-plugin-core-public.savedobjectreferencewithcontext.type.md) + +## SavedObjectReferenceWithContext.type property + +The type of the referenced object + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.md new file mode 100644 index 0000000000000..a6e0a274008a6 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsCollectMultiNamespaceReferencesResponse](./kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.md) + +## SavedObjectsCollectMultiNamespaceReferencesResponse interface + +The response when object references are collected. + +Signature: + +```typescript +export interface SavedObjectsCollectMultiNamespaceReferencesResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [objects](./kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.objects.md) | SavedObjectReferenceWithContext[] | | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.objects.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.objects.md new file mode 100644 index 0000000000000..66a7a19d18288 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.objects.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsCollectMultiNamespaceReferencesResponse](./kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.md) > [objects](./kibana-plugin-core-public.savedobjectscollectmultinamespacereferencesresponse.objects.md) + +## SavedObjectsCollectMultiNamespaceReferencesResponse.objects property + +Signature: + +```typescript +objects: SavedObjectReferenceWithContext[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.find.md b/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.find.md index 1755ff40c2bc0..29d4668becffc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.find.md +++ b/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.find.md @@ -9,5 +9,5 @@ An async generator which wraps calls to `savedObjectsClient.find` and iterates o Signature: ```typescript -find: () => AsyncGenerator; +find: () => AsyncGenerator>; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.md b/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.md index 4686df18e0134..950d6c078654c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.md +++ b/docs/development/core/server/kibana-plugin-core-server.isavedobjectspointintimefinder.md @@ -8,7 +8,7 @@ Signature: ```typescript -export interface ISavedObjectsPointInTimeFinder +export interface ISavedObjectsPointInTimeFinder ``` ## Properties @@ -16,5 +16,5 @@ export interface ISavedObjectsPointInTimeFinder | Property | Type | Description | | --- | --- | --- | | [close](./kibana-plugin-core-server.isavedobjectspointintimefinder.close.md) | () => Promise<void> | Closes the Point-In-Time associated with this finder instance.Once you have retrieved all of the results you need, it is recommended to call close() to clean up the PIT and prevent Elasticsearch from consuming resources unnecessarily. This is only required if you are done iterating and have not yet paged through all of the results: the PIT will automatically be closed for you once you reach the last page of results, or if the underlying call to find fails for any reason. | -| [find](./kibana-plugin-core-server.isavedobjectspointintimefinder.find.md) | () => AsyncGenerator<SavedObjectsFindResponse> | An async generator which wraps calls to savedObjectsClient.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 size. | +| [find](./kibana-plugin-core-server.isavedobjectspointintimefinder.find.md) | () => AsyncGenerator<SavedObjectsFindResponse<T, A>> | An async generator which wraps calls to savedObjectsClient.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 size. | diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 3a9118a9c56bd..d638b84224e23 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -144,8 +144,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) | Migration context provided when invoking a [migration handler](./kibana-plugin-core-server.savedobjectmigrationfn.md) | | [SavedObjectMigrationMap](./kibana-plugin-core-server.savedobjectmigrationmap.md) | A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions, and they cannot exceed the current Kibana version.For a given document, only migrations with a higher version number than that of the document will be applied. Migrations are executed in order, starting from the lowest version and ending with the highest one. | | [SavedObjectReference](./kibana-plugin-core-server.savedobjectreference.md) | A reference to another saved object. | -| [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) | | -| [SavedObjectsAddToNamespacesResponse](./kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.md) | | +| [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) | A returned input object or one of its references, with additional context. | | [SavedObjectsBaseOptions](./kibana-plugin-core-server.savedobjectsbaseoptions.md) | | | [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) | | | [SavedObjectsBulkGetObject](./kibana-plugin-core-server.savedobjectsbulkgetobject.md) | | @@ -158,13 +157,14 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsClientProviderOptions](./kibana-plugin-core-server.savedobjectsclientprovideroptions.md) | Options to control the creation of the Saved Objects Client. | | [SavedObjectsClientWrapperOptions](./kibana-plugin-core-server.savedobjectsclientwrapperoptions.md) | Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. | | [SavedObjectsClosePointInTimeResponse](./kibana-plugin-core-server.savedobjectsclosepointintimeresponse.md) | | +| [SavedObjectsCollectMultiNamespaceReferencesObject](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.md) | An object to collect references for. It must be a multi-namespace type (in other words, the object type must be registered with the namespaceType: 'multi' or namespaceType: 'multi-isolated' option).Note: if options.purpose is 'updateObjectsSpaces', it must be a shareable type (in other words, the object type must be registered with the namespaceType: 'multi'). | +| [SavedObjectsCollectMultiNamespaceReferencesOptions](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.md) | Options for collecting references. | +| [SavedObjectsCollectMultiNamespaceReferencesResponse](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.md) | The response when object references are collected. | | [SavedObjectsComplexFieldMapping](./kibana-plugin-core-server.savedobjectscomplexfieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCoreFieldMapping](./kibana-plugin-core-server.savedobjectscorefieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) | | | [SavedObjectsCreatePointInTimeFinderDependencies](./kibana-plugin-core-server.savedobjectscreatepointintimefinderdependencies.md) | | | [SavedObjectsDeleteByNamespaceOptions](./kibana-plugin-core-server.savedobjectsdeletebynamespaceoptions.md) | | -| [SavedObjectsDeleteFromNamespacesOptions](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md) | | -| [SavedObjectsDeleteFromNamespacesResponse](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.md) | | | [SavedObjectsDeleteOptions](./kibana-plugin-core-server.savedobjectsdeleteoptions.md) | | | [SavedObjectsExportByObjectOptions](./kibana-plugin-core-server.savedobjectsexportbyobjectoptions.md) | Options for the [export by objects API](./kibana-plugin-core-server.savedobjectsexporter.exportbyobjects.md) | | [SavedObjectsExportByTypeOptions](./kibana-plugin-core-server.savedobjectsexportbytypeoptions.md) | Options for the [export by type API](./kibana-plugin-core-server.savedobjectsexporter.exportbytypes.md) | @@ -208,6 +208,10 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md) | | | [SavedObjectsTypeManagementDefinition](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) | Configuration options for the [type](./kibana-plugin-core-server.savedobjectstype.md)'s management section. | | [SavedObjectsTypeMappingDefinition](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) | Describe a saved object type mapping. | +| [SavedObjectsUpdateObjectsSpacesObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.md) | An object that should have its spaces updated. | +| [SavedObjectsUpdateObjectsSpacesOptions](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.md) | Options for the update operation. | +| [SavedObjectsUpdateObjectsSpacesResponse](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.md) | The response when objects' spaces are updated. | +| [SavedObjectsUpdateObjectsSpacesResponseObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md) | Details about a specific object's update result. | | [SavedObjectsUpdateOptions](./kibana-plugin-core-server.savedobjectsupdateoptions.md) | | | [SavedObjectsUpdateResponse](./kibana-plugin-core-server.savedobjectsupdateresponse.md) | | | [SearchResponse](./kibana-plugin-core-server.searchresponse.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectexportbaseoptions.includenamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectexportbaseoptions.includenamespaces.md new file mode 100644 index 0000000000000..8ac532c601efc --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectexportbaseoptions.includenamespaces.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectExportBaseOptions](./kibana-plugin-core-server.savedobjectexportbaseoptions.md) > [includeNamespaces](./kibana-plugin-core-server.savedobjectexportbaseoptions.includenamespaces.md) + +## SavedObjectExportBaseOptions.includeNamespaces property + +Flag to also include namespace information in the export stream. By default, namespace information is not included in exported objects. This is only intended to be used internally during copy-to-space operations, and it is not exposed as an option for the external HTTP route for exports. + +Signature: + +```typescript +includeNamespaces?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectexportbaseoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectexportbaseoptions.md index 0e8fa73039d40..cd0c352086425 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectexportbaseoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectexportbaseoptions.md @@ -16,6 +16,7 @@ export interface SavedObjectExportBaseOptions | Property | Type | Description | | --- | --- | --- | | [excludeExportDetails](./kibana-plugin-core-server.savedobjectexportbaseoptions.excludeexportdetails.md) | boolean | flag to not append [export details](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) to the end of the export stream. | +| [includeNamespaces](./kibana-plugin-core-server.savedobjectexportbaseoptions.includenamespaces.md) | boolean | Flag to also include namespace information in the export stream. By default, namespace information is not included in exported objects. This is only intended to be used internally during copy-to-space operations, and it is not exposed as an option for the external HTTP route for exports. | | [includeReferencesDeep](./kibana-plugin-core-server.savedobjectexportbaseoptions.includereferencesdeep.md) | boolean | flag to also include all related saved objects in the export stream. | | [namespace](./kibana-plugin-core-server.savedobjectexportbaseoptions.namespace.md) | string | optional namespace to override the namespace used by the savedObjectsClient. | | [request](./kibana-plugin-core-server.savedobjectexportbaseoptions.request.md) | KibanaRequest | The http request initiating the export. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.id.md new file mode 100644 index 0000000000000..7ef1a2fb1bd41 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.id.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) > [id](./kibana-plugin-core-server.savedobjectreferencewithcontext.id.md) + +## SavedObjectReferenceWithContext.id property + +The ID of the referenced object + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md new file mode 100644 index 0000000000000..058c27032d065 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) > [inboundReferences](./kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md) + +## SavedObjectReferenceWithContext.inboundReferences property + +References to this object; note that this does not contain \_all inbound references everywhere for this object\_, it only contains inbound references for the scope of this operation + +Signature: + +```typescript +inboundReferences: Array<{ + type: string; + id: string; + name: string; + }>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.ismissing.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.ismissing.md new file mode 100644 index 0000000000000..d46d5a6bf2a0a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.ismissing.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) > [isMissing](./kibana-plugin-core-server.savedobjectreferencewithcontext.ismissing.md) + +## SavedObjectReferenceWithContext.isMissing property + +Whether or not this object or reference is missing + +Signature: + +```typescript +isMissing?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md new file mode 100644 index 0000000000000..1f8b33c6e94e8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) + +## SavedObjectReferenceWithContext interface + +A returned input object or one of its references, with additional context. + +Signature: + +```typescript +export interface SavedObjectReferenceWithContext +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectreferencewithcontext.id.md) | string | The ID of the referenced object | +| [inboundReferences](./kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md) | Array<{
type: string;
id: string;
name: string;
}> | References to this object; note that this does not contain \_all inbound references everywhere for this object\_, it only contains inbound references for the scope of this operation | +| [isMissing](./kibana-plugin-core-server.savedobjectreferencewithcontext.ismissing.md) | boolean | Whether or not this object or reference is missing | +| [spaces](./kibana-plugin-core-server.savedobjectreferencewithcontext.spaces.md) | string[] | The space(s) that the referenced object exists in | +| [spacesWithMatchingAliases](./kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingaliases.md) | string[] | The space(s) that legacy URL aliases matching this type/id exist in | +| [type](./kibana-plugin-core-server.savedobjectreferencewithcontext.type.md) | string | The type of the referenced object | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaces.md new file mode 100644 index 0000000000000..2c2114103b29a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaces.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) > [spaces](./kibana-plugin-core-server.savedobjectreferencewithcontext.spaces.md) + +## SavedObjectReferenceWithContext.spaces property + +The space(s) that the referenced object exists in + +Signature: + +```typescript +spaces: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingaliases.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingaliases.md new file mode 100644 index 0000000000000..07f4158a84950 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingaliases.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) > [spacesWithMatchingAliases](./kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingaliases.md) + +## SavedObjectReferenceWithContext.spacesWithMatchingAliases property + +The space(s) that legacy URL aliases matching this type/id exist in + +Signature: + +```typescript +spacesWithMatchingAliases?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.type.md new file mode 100644 index 0000000000000..118d9744e4276 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.type.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) > [type](./kibana-plugin-core-server.savedobjectreferencewithcontext.type.md) + +## SavedObjectReferenceWithContext.type property + +The type of the referenced object + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md deleted file mode 100644 index 711588bdd608c..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md +++ /dev/null @@ -1,20 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) - -## SavedObjectsAddToNamespacesOptions interface - - -Signature: - -```typescript -export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOptions -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [refresh](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | -| [version](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md) | string | An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. | - diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md deleted file mode 100644 index c0a1008ab5331..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) > [refresh](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md) - -## SavedObjectsAddToNamespacesOptions.refresh property - -The Elasticsearch Refresh setting for this operation - -Signature: - -```typescript -refresh?: MutatingOperationRefreshSetting; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md deleted file mode 100644 index 9432b4bf80da6..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) > [version](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md) - -## SavedObjectsAddToNamespacesOptions.version property - -An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. - -Signature: - -```typescript -version?: string; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.md deleted file mode 100644 index 306f502f0b0b3..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.md +++ /dev/null @@ -1,19 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesResponse](./kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.md) - -## SavedObjectsAddToNamespacesResponse interface - - -Signature: - -```typescript -export interface SavedObjectsAddToNamespacesResponse -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [namespaces](./kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.namespaces.md) | string[] | The namespaces the object exists in after this operation is complete. | - diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.namespaces.md deleted file mode 100644 index 4fc2e376304d4..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.namespaces.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesResponse](./kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.md) > [namespaces](./kibana-plugin-core-server.savedobjectsaddtonamespacesresponse.namespaces.md) - -## SavedObjectsAddToNamespacesResponse.namespaces property - -The namespaces the object exists in after this operation is complete. - -Signature: - -```typescript -namespaces: string[]; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md deleted file mode 100644 index 567390faba9b2..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md +++ /dev/null @@ -1,27 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [addToNamespaces](./kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md) - -## SavedObjectsClient.addToNamespaces() method - -Adds namespaces to a SavedObject - -Signature: - -```typescript -addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| type | string | | -| id | string | | -| namespaces | string[] | | -| options | SavedObjectsAddToNamespacesOptions | | - -Returns: - -`Promise` - diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.collectmultinamespacereferences.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.collectmultinamespacereferences.md new file mode 100644 index 0000000000000..155167d32a738 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.collectmultinamespacereferences.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [collectMultiNamespaceReferences](./kibana-plugin-core-server.savedobjectsclient.collectmultinamespacereferences.md) + +## SavedObjectsClient.collectMultiNamespaceReferences() method + +Gets all references and transitive references of the listed objects. Ignores any object that is not a multi-namespace type. + +Signature: + +```typescript +collectMultiNamespaceReferences(objects: SavedObjectsCollectMultiNamespaceReferencesObject[], options?: SavedObjectsCollectMultiNamespaceReferencesOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| objects | SavedObjectsCollectMultiNamespaceReferencesObject[] | | +| options | SavedObjectsCollectMultiNamespaceReferencesOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md index 8afd963464574..39d09807e4f3b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md @@ -15,7 +15,7 @@ Once you have retrieved all of the results you need, it is recommended to call ` Signature: ```typescript -createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; +createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; ``` ## Parameters @@ -27,7 +27,7 @@ createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, Returns: -`ISavedObjectsPointInTimeFinder` +`ISavedObjectsPointInTimeFinder` ## Example diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md deleted file mode 100644 index 18ef5c3e6350c..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md +++ /dev/null @@ -1,27 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [deleteFromNamespaces](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) - -## SavedObjectsClient.deleteFromNamespaces() method - -Removes namespaces from a SavedObject - -Signature: - -```typescript -deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| type | string | | -| id | string | | -| namespaces | string[] | | -| options | SavedObjectsDeleteFromNamespacesOptions | | - -Returns: - -`Promise` - 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 95c2251f72c90..2e293889b1794 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -25,20 +25,20 @@ The constructor for this class is marked as internal. Third-party code should no | Method | Modifiers | Description | | --- | --- | --- | -| [addToNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md) | | Adds namespaces to a SavedObject | | [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 | | [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. | +| [collectMultiNamespaceReferences(objects, options)](./kibana-plugin-core-server.savedobjectsclient.collectmultinamespacereferences.md) | | Gets all references and transitive references of the listed objects. Ignores any object that is not a multi-namespace type. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.create.md) | | Persists a SavedObject | | [createPointInTimeFinder(findOptions, dependencies)](./kibana-plugin-core-server.savedobjectsclient.createpointintimefinder.md) | | Returns a [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) to help page through large sets of saved objects. We strongly recommend using this API for any find queries that might return more than 1000 saved objects, however this API is only intended for use in server-side "batch" processing of objects where you are collecting all objects in memory or streaming them back to the client.Do NOT use this API in a route handler to facilitate paging through saved objects on the client-side unless you are streaming all of the results back to the client at once. Because the returned generator is stateful, you cannot rely on subsequent http requests retrieving new pages from the same Kibana server in multi-instance deployments.The generator wraps calls to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) 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.Once you have retrieved all of the results you need, it is recommended to call close() to clean up the PIT and prevent Elasticsearch from consuming resources unnecessarily. This is only required if you are done iterating and have not yet paged through all of the results: the PIT will automatically be closed for you once you reach the last page of results, or if the underlying call to find fails for any reason. | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.delete.md) | | Deletes a SavedObject | -| [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) | | Removes namespaces from a SavedObject | | [find(options)](./kibana-plugin-core-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.get.md) | | Retrieves a single object | | [openPointInTimeForType(type, options)](./kibana-plugin-core-server.savedobjectsclient.openpointintimefortype.md) | | Opens a Point In Time (PIT) against the indices for the specified Saved Object types. The returned id can then be passed to [SavedObjectsClient.find()](./kibana-plugin-core-server.savedobjectsclient.find.md) to search against that PIT.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. | | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.update.md) | | Updates an SavedObject | +| [updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options)](./kibana-plugin-core-server.savedobjectsclient.updateobjectsspaces.md) | | Updates one or more objects to add and/or remove them from specified spaces. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.updateobjectsspaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.updateobjectsspaces.md new file mode 100644 index 0000000000000..7ababbbe1f535 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.updateobjectsspaces.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [updateObjectsSpaces](./kibana-plugin-core-server.savedobjectsclient.updateobjectsspaces.md) + +## SavedObjectsClient.updateObjectsSpaces() method + +Updates one or more objects to add and/or remove them from specified spaces. + +Signature: + +```typescript +updateObjectsSpaces(objects: SavedObjectsUpdateObjectsSpacesObject[], spacesToAdd: string[], spacesToRemove: string[], options?: SavedObjectsUpdateObjectsSpacesOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| objects | SavedObjectsUpdateObjectsSpacesObject[] | | +| spacesToAdd | string[] | | +| spacesToRemove | string[] | | +| options | SavedObjectsUpdateObjectsSpacesOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.id.md new file mode 100644 index 0000000000000..21522a0f32d6d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.id.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCollectMultiNamespaceReferencesObject](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.md) > [id](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.id.md) + +## SavedObjectsCollectMultiNamespaceReferencesObject.id property + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.md new file mode 100644 index 0000000000000..e675658f2bf76 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCollectMultiNamespaceReferencesObject](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.md) + +## SavedObjectsCollectMultiNamespaceReferencesObject interface + +An object to collect references for. It must be a multi-namespace type (in other words, the object type must be registered with the `namespaceType: 'multi'` or `namespaceType: 'multi-isolated'` option). + +Note: if options.purpose is 'updateObjectsSpaces', it must be a shareable type (in other words, the object type must be registered with the `namespaceType: 'multi'`). + +Signature: + +```typescript +export interface SavedObjectsCollectMultiNamespaceReferencesObject +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.id.md) | string | | +| [type](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.type.md) | string | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.type.md new file mode 100644 index 0000000000000..c376a9e4258c8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.type.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCollectMultiNamespaceReferencesObject](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.md) > [type](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesobject.type.md) + +## SavedObjectsCollectMultiNamespaceReferencesObject.type property + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.md new file mode 100644 index 0000000000000..9311a66269753 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCollectMultiNamespaceReferencesOptions](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.md) + +## SavedObjectsCollectMultiNamespaceReferencesOptions interface + +Options for collecting references. + +Signature: + +```typescript +export interface SavedObjectsCollectMultiNamespaceReferencesOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [purpose](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.purpose.md) | 'collectMultiNamespaceReferences' | 'updateObjectsSpaces' | Optional purpose used to determine filtering and authorization checks; default is 'collectMultiNamespaceReferences' | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.purpose.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.purpose.md new file mode 100644 index 0000000000000..a36301a6451bc --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.purpose.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCollectMultiNamespaceReferencesOptions](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.md) > [purpose](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesoptions.purpose.md) + +## SavedObjectsCollectMultiNamespaceReferencesOptions.purpose property + +Optional purpose used to determine filtering and authorization checks; default is 'collectMultiNamespaceReferences' + +Signature: + +```typescript +purpose?: 'collectMultiNamespaceReferences' | 'updateObjectsSpaces'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.md new file mode 100644 index 0000000000000..bc72e73994468 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCollectMultiNamespaceReferencesResponse](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.md) + +## SavedObjectsCollectMultiNamespaceReferencesResponse interface + +The response when object references are collected. + +Signature: + +```typescript +export interface SavedObjectsCollectMultiNamespaceReferencesResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [objects](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.objects.md) | SavedObjectReferenceWithContext[] | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.objects.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.objects.md new file mode 100644 index 0000000000000..4b5707d7228a5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.objects.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCollectMultiNamespaceReferencesResponse](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.md) > [objects](./kibana-plugin-core-server.savedobjectscollectmultinamespacereferencesresponse.objects.md) + +## SavedObjectsCollectMultiNamespaceReferencesResponse.objects property + +Signature: + +```typescript +objects: SavedObjectReferenceWithContext[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md deleted file mode 100644 index 8a2afe6656fa4..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md +++ /dev/null @@ -1,19 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsDeleteFromNamespacesOptions](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md) - -## SavedObjectsDeleteFromNamespacesOptions interface - - -Signature: - -```typescript -export interface SavedObjectsDeleteFromNamespacesOptions extends SavedObjectsBaseOptions -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [refresh](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | - diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md deleted file mode 100644 index 1175b79bc1abd..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsDeleteFromNamespacesOptions](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md) > [refresh](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md) - -## SavedObjectsDeleteFromNamespacesOptions.refresh property - -The Elasticsearch Refresh setting for this operation - -Signature: - -```typescript -refresh?: MutatingOperationRefreshSetting; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.md deleted file mode 100644 index 6021c8866f018..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.md +++ /dev/null @@ -1,19 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsDeleteFromNamespacesResponse](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.md) - -## SavedObjectsDeleteFromNamespacesResponse interface - - -Signature: - -```typescript -export interface SavedObjectsDeleteFromNamespacesResponse -``` - -## Properties - -| Property | Type | Description | -| --- | --- | --- | -| [namespaces](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.namespaces.md) | string[] | The namespaces the object exists in after this operation is complete. An empty array indicates the object was deleted. | - diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.namespaces.md deleted file mode 100644 index 9600a9e891380..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.namespaces.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsDeleteFromNamespacesResponse](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.md) > [namespaces](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesresponse.namespaces.md) - -## SavedObjectsDeleteFromNamespacesResponse.namespaces property - -The namespaces the object exists in after this operation is complete. An empty array indicates the object was deleted. - -Signature: - -```typescript -namespaces: string[]; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md deleted file mode 100644 index 4b69b10318ed3..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md +++ /dev/null @@ -1,27 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [addToNamespaces](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) - -## SavedObjectsRepository.addToNamespaces() method - -Adds one or more namespaces to a given multi-namespace saved object. This method and \[`deleteFromNamespaces`\][SavedObjectsRepository.deleteFromNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. - -Signature: - -```typescript -addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| type | string | | -| id | string | | -| namespaces | string[] | | -| options | SavedObjectsAddToNamespacesOptions | | - -Returns: - -`Promise` - diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.collectmultinamespacereferences.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.collectmultinamespacereferences.md new file mode 100644 index 0000000000000..450cd14a20524 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.collectmultinamespacereferences.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [collectMultiNamespaceReferences](./kibana-plugin-core-server.savedobjectsrepository.collectmultinamespacereferences.md) + +## SavedObjectsRepository.collectMultiNamespaceReferences() method + +Gets all references and transitive references of the given objects. Ignores any object and/or reference that is not a multi-namespace type. + +Signature: + +```typescript +collectMultiNamespaceReferences(objects: SavedObjectsCollectMultiNamespaceReferencesObject[], options?: SavedObjectsCollectMultiNamespaceReferencesOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| objects | SavedObjectsCollectMultiNamespaceReferencesObject[] | | +| options | SavedObjectsCollectMultiNamespaceReferencesOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md index 5d9d2857f6e0b..c92a1986966fd 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md @@ -15,7 +15,7 @@ Once you have retrieved all of the results you need, it is recommended to call ` Signature: ```typescript -createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; +createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; ``` ## Parameters @@ -27,7 +27,7 @@ createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, Returns: -`ISavedObjectsPointInTimeFinder` +`ISavedObjectsPointInTimeFinder` ## Example diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md deleted file mode 100644 index d5ffb6d9ff9d8..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md +++ /dev/null @@ -1,27 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [deleteFromNamespaces](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) - -## SavedObjectsRepository.deleteFromNamespaces() method - -Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[`addToNamespaces`\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. - -Signature: - -```typescript -deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; -``` - -## Parameters - -| Parameter | Type | Description | -| --- | --- | --- | -| type | string | | -| id | string | | -| namespaces | string[] | | -| options | SavedObjectsDeleteFromNamespacesOptions | | - -Returns: - -`Promise` - 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 00e6ed3aeddfc..191b125ef3f74 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -15,17 +15,16 @@ export declare class SavedObjectsRepository | Method | Modifiers | Description | | --- | --- | --- | -| [addToNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) | | Adds one or more namespaces to a given multi-namespace saved object. This method and \[deleteFromNamespaces\][SavedObjectsRepository.deleteFromNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | | [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 | | [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. | +| [collectMultiNamespaceReferences(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.collectmultinamespacereferences.md) | | Gets all references and transitive references of the given objects. Ignores any object and/or reference that is not a multi-namespace type. | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.create.md) | | Persists an object | | [createPointInTimeFinder(findOptions, dependencies)](./kibana-plugin-core-server.savedobjectsrepository.createpointintimefinder.md) | | Returns a [ISavedObjectsPointInTimeFinder](./kibana-plugin-core-server.isavedobjectspointintimefinder.md) to help page through large sets of saved objects. We strongly recommend using this API for any find queries that might return more than 1000 saved objects, however this API is only intended for use in server-side "batch" processing of objects where you are collecting all objects in memory or streaming them back to the client.Do NOT use this API in a route handler to facilitate paging through saved objects on the client-side unless you are streaming all of the results back to the client at once. Because the returned generator is stateful, you cannot rely on subsequent http requests retrieving new pages from the same Kibana server in multi-instance deployments.This generator wraps calls to [SavedObjectsRepository.find()](./kibana-plugin-core-server.savedobjectsrepository.find.md) 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.Once you have retrieved all of the results you need, it is recommended to call close() to clean up the PIT and prevent Elasticsearch from consuming resources unnecessarily. This is only required if you are done iterating and have not yet paged through all of the results: the PIT will automatically be closed for you once you reach the last page of results, or if the underlying call to find fails for any reason. | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | -| [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | | [find(options)](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFields, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increments all the specified counter fields (by one by default). Creates the document if one doesn't exist for the given id. | @@ -33,4 +32,5 @@ export declare class SavedObjectsRepository | [removeReferencesTo(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.removereferencesto.md) | | Updates all objects containing a reference to the given {type, id} tuple to remove the said reference. | | [resolve(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.resolve.md) | | Resolves a single object, using any legacy URL alias if it exists | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | +| [updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options)](./kibana-plugin-core-server.savedobjectsrepository.updateobjectsspaces.md) | | Updates one or more objects to add and/or remove them from specified spaces. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.updateobjectsspaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.updateobjectsspaces.md new file mode 100644 index 0000000000000..6914c1b46b829 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.updateobjectsspaces.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [updateObjectsSpaces](./kibana-plugin-core-server.savedobjectsrepository.updateobjectsspaces.md) + +## SavedObjectsRepository.updateObjectsSpaces() method + +Updates one or more objects to add and/or remove them from specified spaces. + +Signature: + +```typescript +updateObjectsSpaces(objects: SavedObjectsUpdateObjectsSpacesObject[], spacesToAdd: string[], spacesToRemove: string[], options?: SavedObjectsUpdateObjectsSpacesOptions): Promise; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| objects | SavedObjectsUpdateObjectsSpacesObject[] | | +| spacesToAdd | string[] | | +| spacesToRemove | string[] | | +| options | SavedObjectsUpdateObjectsSpacesOptions | | + +Returns: + +`Promise` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md index 3fc386f263141..d71db9caf6a3b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.rawtosavedobject.md @@ -9,7 +9,7 @@ Converts a document from the format that is stored in elasticsearch to the saved Signature: ```typescript -rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): SavedObjectSanitizedDoc; +rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): SavedObjectSanitizedDoc; ``` ## Parameters @@ -21,5 +21,5 @@ rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptio Returns: -`SavedObjectSanitizedDoc` +`SavedObjectSanitizedDoc` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.id.md new file mode 100644 index 0000000000000..dac110ac4f475 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.id.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.md) > [id](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.id.md) + +## SavedObjectsUpdateObjectsSpacesObject.id property + +The type of the object to update + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.md new file mode 100644 index 0000000000000..847e40a8896b4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.md) + +## SavedObjectsUpdateObjectsSpacesObject interface + +An object that should have its spaces updated. + +Signature: + +```typescript +export interface SavedObjectsUpdateObjectsSpacesObject +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [id](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.id.md) | string | The type of the object to update | +| [type](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.type.md) | string | The ID of the object to update | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.type.md new file mode 100644 index 0000000000000..2e54d1636c5e9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.type.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.md) > [type](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesobject.type.md) + +## SavedObjectsUpdateObjectsSpacesObject.type property + +The ID of the object to update + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.md new file mode 100644 index 0000000000000..49ee013c5d2da --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesOptions](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.md) + +## SavedObjectsUpdateObjectsSpacesOptions interface + +Options for the update operation. + +Signature: + +```typescript +export interface SavedObjectsUpdateObjectsSpacesOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [refresh](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.refresh.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.refresh.md new file mode 100644 index 0000000000000..3d210f6ac51c7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.refresh.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesOptions](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.md) > [refresh](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesoptions.refresh.md) + +## SavedObjectsUpdateObjectsSpacesOptions.refresh property + +The Elasticsearch Refresh setting for this operation + +Signature: + +```typescript +refresh?: MutatingOperationRefreshSetting; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.md new file mode 100644 index 0000000000000..bf53277887bda --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesResponse](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.md) + +## SavedObjectsUpdateObjectsSpacesResponse interface + +The response when objects' spaces are updated. + +Signature: + +```typescript +export interface SavedObjectsUpdateObjectsSpacesResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [objects](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.objects.md) | SavedObjectsUpdateObjectsSpacesResponseObject[] | | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.objects.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.objects.md new file mode 100644 index 0000000000000..13328e2aed094 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.objects.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesResponse](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.md) > [objects](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponse.objects.md) + +## SavedObjectsUpdateObjectsSpacesResponse.objects property + +Signature: + +```typescript +objects: SavedObjectsUpdateObjectsSpacesResponseObject[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.error.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.error.md new file mode 100644 index 0000000000000..7d7ac4ada884d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.error.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesResponseObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md) > [error](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.error.md) + +## SavedObjectsUpdateObjectsSpacesResponseObject.error property + +Included if there was an error updating this object's spaces + +Signature: + +```typescript +error?: SavedObjectError; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.id.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.id.md new file mode 100644 index 0000000000000..28a81ee5dfd6a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.id.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesResponseObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md) > [id](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.id.md) + +## SavedObjectsUpdateObjectsSpacesResponseObject.id property + +The ID of the referenced object + +Signature: + +```typescript +id: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md new file mode 100644 index 0000000000000..03802278ee5a3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesResponseObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md) + +## SavedObjectsUpdateObjectsSpacesResponseObject interface + +Details about a specific object's update result. + +Signature: + +```typescript +export interface SavedObjectsUpdateObjectsSpacesResponseObject +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [error](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.error.md) | SavedObjectError | Included if there was an error updating this object's spaces | +| [id](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.id.md) | string | The ID of the referenced object | +| [spaces](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.spaces.md) | string[] | The space(s) that the referenced object exists in | +| [type](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.type.md) | string | The type of the referenced object | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.spaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.spaces.md new file mode 100644 index 0000000000000..52b1ca187925c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.spaces.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesResponseObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md) > [spaces](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.spaces.md) + +## SavedObjectsUpdateObjectsSpacesResponseObject.spaces property + +The space(s) that the referenced object exists in + +Signature: + +```typescript +spaces: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.type.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.type.md new file mode 100644 index 0000000000000..da0bbb1088507 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.type.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUpdateObjectsSpacesResponseObject](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.md) > [type](./kibana-plugin-core-server.savedobjectsupdateobjectsspacesresponseobject.type.md) + +## SavedObjectsUpdateObjectsSpacesResponseObject.type property + +The type of the referenced object + +Signature: + +```typescript +type: string; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md index 118b0104fbee6..7559695a0a331 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.indexpatternsserviceprovider.start.md @@ -8,7 +8,7 @@ ```typescript start(core: CoreStart, { fieldFormats, logger }: IndexPatternsServiceStartDeps): { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; }; ``` @@ -22,6 +22,6 @@ start(core: CoreStart, { fieldFormats, logger }: IndexPatternsServiceStartDeps): Returns: `{ - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient) => Promise; }` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md index f4404521561d2..dd1f3806c1408 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md @@ -12,7 +12,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; @@ -31,7 +31,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }` diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 17ba37d075b78..7d2a585084758 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -144,6 +144,8 @@ export type { SavedObjectsImportSimpleWarning, SavedObjectsImportActionRequiredWarning, SavedObjectsImportWarning, + SavedObjectReferenceWithContext, + SavedObjectsCollectMultiNamespaceReferencesResponse, } from './saved_objects'; export { HttpFetchError } from './http'; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 4ea3b56c60a8f..129a7e565394f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1173,6 +1173,20 @@ export interface SavedObjectReference { type: string; } +// @public +export interface SavedObjectReferenceWithContext { + id: string; + inboundReferences: Array<{ + type: string; + id: string; + name: string; + }>; + isMissing?: boolean; + spaces: string[]; + spacesWithMatchingAliases?: string[]; + type: string; +} + // @public (undocumented) export interface SavedObjectsBaseOptions { namespace?: string; @@ -1240,6 +1254,12 @@ export class SavedObjectsClient { // @public export type SavedObjectsClientContract = PublicMethodsOf; +// @public +export interface SavedObjectsCollectMultiNamespaceReferencesResponse { + // (undocumented) + objects: SavedObjectReferenceWithContext[]; +} + // @public (undocumented) export interface SavedObjectsCreateOptions { coreMigrationVersion?: string; diff --git a/src/core/public/saved_objects/index.ts b/src/core/public/saved_objects/index.ts index e8aef50376841..cd75bc16f8362 100644 --- a/src/core/public/saved_objects/index.ts +++ b/src/core/public/saved_objects/index.ts @@ -39,6 +39,8 @@ export type { SavedObjectsImportSimpleWarning, SavedObjectsImportActionRequiredWarning, SavedObjectsImportWarning, + SavedObjectReferenceWithContext, + SavedObjectsCollectMultiNamespaceReferencesResponse, } from '../../server/types'; export type { diff --git a/src/core/server/index.ts b/src/core/server/index.ts index ca328f17b2ae1..05408d839c0ae 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -320,12 +320,16 @@ export type { SavedObjectsResolveResponse, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, - SavedObjectsAddToNamespacesOptions, - SavedObjectsAddToNamespacesResponse, - SavedObjectsDeleteFromNamespacesOptions, - SavedObjectsDeleteFromNamespacesResponse, SavedObjectsRemoveReferencesToOptions, SavedObjectsRemoveReferencesToResponse, + SavedObjectsCollectMultiNamespaceReferencesObject, + SavedObjectsCollectMultiNamespaceReferencesOptions, + SavedObjectReferenceWithContext, + SavedObjectsCollectMultiNamespaceReferencesResponse, + SavedObjectsUpdateObjectsSpacesObject, + SavedObjectsUpdateObjectsSpacesOptions, + SavedObjectsUpdateObjectsSpacesResponse, + SavedObjectsUpdateObjectsSpacesResponseObject, SavedObjectsServiceStart, SavedObjectsServiceSetup, SavedObjectStatusMeta, diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts index 468a761781365..6bdb8003de49d 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.test.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.test.ts @@ -1149,6 +1149,29 @@ describe('getSortedObjectsForExport()', () => { ]); }); + test('return results including the `namespaces` attribute when includeNamespaces option is used', async () => { + const createSavedObject = (obj: any) => ({ ...obj, attributes: {}, references: [] }); + const objectResults = [ + createSavedObject({ type: 'multi', id: '1', namespaces: ['foo'] }), + createSavedObject({ type: 'multi', id: '2', namespaces: ['bar'] }), + createSavedObject({ type: 'other', id: '3' }), + ]; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: objectResults, + }); + const exportStream = await exporter.exportByObjects({ + request, + objects: [ + { type: 'multi', id: '1' }, + { type: 'multi', id: '2' }, + { type: 'other', id: '3' }, + ], + includeNamespaces: true, + }); + const response = await readStreamToCompletion(exportStream); + expect(response).toEqual([...objectResults, expect.objectContaining({ exportedCount: 3 })]); + }); + test('includes nested dependencies when passed in', async () => { savedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.ts b/src/core/server/saved_objects/export/saved_objects_exporter.ts index 868efa872d643..8cd6934bf1af9 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts @@ -77,6 +77,7 @@ export class SavedObjectsExporter { return this.processObjects(objects, byIdAscComparator, { request: options.request, includeReferencesDeep: options.includeReferencesDeep, + includeNamespaces: options.includeNamespaces, excludeExportDetails: options.excludeExportDetails, namespace: options.namespace, }); @@ -99,6 +100,7 @@ export class SavedObjectsExporter { return this.processObjects(objects, comparator, { request: options.request, includeReferencesDeep: options.includeReferencesDeep, + includeNamespaces: options.includeNamespaces, excludeExportDetails: options.excludeExportDetails, namespace: options.namespace, }); @@ -111,6 +113,7 @@ export class SavedObjectsExporter { request, excludeExportDetails = false, includeReferencesDeep = false, + includeNamespaces = false, namespace, }: SavedObjectExportBaseOptions ) { @@ -139,9 +142,9 @@ export class SavedObjectsExporter { } // redact attributes that should not be exported - const redactedObjects = exportedObjects.map>( - ({ namespaces, ...object }) => object - ); + const redactedObjects = includeNamespaces + ? exportedObjects + : exportedObjects.map>(({ namespaces, ...object }) => object); const exportDetails: SavedObjectsExportResultDetails = { exportedCount: exportedObjects.length, diff --git a/src/core/server/saved_objects/export/types.ts b/src/core/server/saved_objects/export/types.ts index 4326943bd31ce..7891af6df5b1b 100644 --- a/src/core/server/saved_objects/export/types.ts +++ b/src/core/server/saved_objects/export/types.ts @@ -15,6 +15,12 @@ export interface SavedObjectExportBaseOptions { request: KibanaRequest; /** flag to also include all related saved objects in the export stream. */ includeReferencesDeep?: boolean; + /** + * Flag to also include namespace information in the export stream. By default, namespace information is not included in exported objects. + * This is only intended to be used internally during copy-to-space operations, and it is not exposed as an option for the external HTTP + * route for exports. + */ + includeNamespaces?: boolean; /** flag to not append {@link SavedObjectsExportResultDetails | export details} to the end of the export stream. */ excludeExportDetails?: boolean; /** optional namespace to override the namespace used by the savedObjectsClient. */ diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 45286f158edb1..71e5565ebcbef 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -982,6 +982,7 @@ describe('DocumentMigrator', () => { id: 'foo-namespace:dog:loud', type: LEGACY_URL_ALIAS_TYPE, attributes: { + sourceId: 'loud', targetNamespace: 'foo-namespace', targetType: 'dog', targetId: 'uuidv5', @@ -1046,6 +1047,7 @@ describe('DocumentMigrator', () => { id: 'foo-namespace:dog:cute', type: LEGACY_URL_ALIAS_TYPE, attributes: { + sourceId: 'cute', targetNamespace: 'foo-namespace', targetType: 'dog', targetId: 'uuidv5', @@ -1168,6 +1170,7 @@ describe('DocumentMigrator', () => { id: 'foo-namespace:dog:hungry', type: LEGACY_URL_ALIAS_TYPE, attributes: { + sourceId: 'hungry', targetNamespace: 'foo-namespace', targetType: 'dog', targetId: 'uuidv5', @@ -1240,6 +1243,7 @@ describe('DocumentMigrator', () => { id: 'foo-namespace:dog:pretty', type: LEGACY_URL_ALIAS_TYPE, attributes: { + sourceId: 'pretty', targetNamespace: 'foo-namespace', targetType: 'dog', targetId: 'uuidv5', diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index 4f58397866cfb..f30cfc53018db 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -560,6 +560,7 @@ function convertNamespaceType(doc: SavedObjectUnsanitizedDoc) { id: `${namespace}:${type}:${originId}`, type: LEGACY_URL_ALIAS_TYPE, attributes: { + sourceId: originId, targetNamespace: namespace, targetType: type, targetId: id, diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts index 9f7e32c49ef15..4a1a2b414a642 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/rewriting_id.test.ts @@ -194,6 +194,7 @@ describe('migration v2', () => { id: 'legacy-url-alias:spacex:foo:1', type: 'legacy-url-alias', 'legacy-url-alias': { + sourceId: '1', targetId: newFooId, targetNamespace: 'spacex', targetType: 'foo', @@ -226,6 +227,7 @@ describe('migration v2', () => { id: 'legacy-url-alias:spacex:bar:1', type: 'legacy-url-alias', 'legacy-url-alias': { + sourceId: '1', targetId: newBarId, targetNamespace: 'spacex', targetType: 'bar', diff --git a/src/core/server/saved_objects/object_types/registration.ts b/src/core/server/saved_objects/object_types/registration.ts index 149fc09ce401d..2b5f49123b2cf 100644 --- a/src/core/server/saved_objects/object_types/registration.ts +++ b/src/core/server/saved_objects/object_types/registration.ts @@ -13,10 +13,15 @@ const legacyUrlAliasType: SavedObjectsType = { name: LEGACY_URL_ALIAS_TYPE, namespaceType: 'agnostic', mappings: { - dynamic: false, // we aren't querying or aggregating over this data, so we don't need to specify any fields - properties: {}, + dynamic: false, + properties: { + sourceId: { type: 'keyword' }, + targetType: { type: 'keyword' }, + disabled: { type: 'boolean' }, + // other properties exist, but we aren't querying or aggregating on those, so we don't need to specify them (because we use `dynamic: false` above) + }, }, - hidden: true, + hidden: false, }; /** diff --git a/src/core/server/saved_objects/object_types/types.ts b/src/core/server/saved_objects/object_types/types.ts index 6fca2ed59906b..9038d1a606067 100644 --- a/src/core/server/saved_objects/object_types/types.ts +++ b/src/core/server/saved_objects/object_types/types.ts @@ -7,13 +7,49 @@ */ /** + * A legacy URL alias is created for an object when it is converted from a single-namespace type to a multi-namespace type. This enables us + * to preserve functionality of existing URLs for objects whose IDs have been changed during the conversion process, by way of the new + * `SavedObjectsClient.resolve()` API. + * + * Legacy URL aliases are only created by the `DocumentMigrator`, and will always have a saved object ID as follows: + * + * ``` + * `${targetNamespace}:${targetType}:${sourceId}` + * ``` + * + * This predictable object ID allows aliases to be easily looked up during the resolve operation, and ensures that exactly one alias will + * exist for a given source per space. + * * @internal */ export interface LegacyUrlAlias { + /** + * The original ID of the object, before it was converted. + */ + sourceId: string; + /** + * The namespace that the object existed in when it was converted. + */ targetNamespace: string; + /** + * The type of the object when it was converted. + */ targetType: string; + /** + * The new ID of the object when it was converted. + */ targetId: string; + /** + * The last time this alias was used with `SavedObjectsClient.resolve()`. + */ lastResolved?: string; + /** + * How many times this alias was used with `SavedObjectsClient.resolve()`. + */ resolveCounter?: number; + /** + * If true, this alias is disabled and it will be ignored in `SavedObjectsClient.resolve()` and + * `SavedObjectsClient.collectMultiNamespaceReferences()`. + */ disabled?: boolean; } diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 4b955032939b3..9c91abcfe79c5 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -76,10 +76,10 @@ export class SavedObjectsSerializer { * @param {SavedObjectsRawDoc} doc - The raw ES document to be converted to saved object format. * @param {SavedObjectsRawDocParseOptions} options - Options for parsing the raw document. */ - public rawToSavedObject( + public rawToSavedObject( doc: SavedObjectsRawDoc, options: SavedObjectsRawDocParseOptions = {} - ): SavedObjectSanitizedDoc { + ): SavedObjectSanitizedDoc { this.checkIsRawSavedObject(doc, options); // throws a descriptive error if the document is not a saved object const { namespaceTreatment = 'strict' } = options; diff --git a/src/core/server/saved_objects/service/index.ts b/src/core/server/saved_objects/service/index.ts index 8a66e6176d1f5..7b4ffcf2dd6cf 100644 --- a/src/core/server/saved_objects/service/index.ts +++ b/src/core/server/saved_objects/service/index.ts @@ -17,6 +17,14 @@ export type { SavedObjectsClientWrapperOptions, SavedObjectsClientFactory, SavedObjectsClientFactoryProvider, + SavedObjectsCollectMultiNamespaceReferencesObject, + SavedObjectsCollectMultiNamespaceReferencesOptions, + SavedObjectReferenceWithContext, + SavedObjectsCollectMultiNamespaceReferencesResponse, + SavedObjectsUpdateObjectsSpacesObject, + SavedObjectsUpdateObjectsSpacesOptions, + SavedObjectsUpdateObjectsSpacesResponse, + SavedObjectsUpdateObjectsSpacesResponseObject, } from './lib'; export * from './saved_objects_client'; diff --git a/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.mock.ts b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.mock.ts new file mode 100644 index 0000000000000..cbd1ac4a8eb8f --- /dev/null +++ b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type * as InternalUtils from './internal_utils'; + +export const mockRawDocExistsInNamespace = jest.fn() as jest.MockedFunction< + typeof InternalUtils['rawDocExistsInNamespace'] +>; + +jest.mock('./internal_utils', () => { + const actual = jest.requireActual('./internal_utils'); + return { + ...actual, + rawDocExistsInNamespace: mockRawDocExistsInNamespace, + }; +}); diff --git a/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.ts b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.ts new file mode 100644 index 0000000000000..00fc039ff005f --- /dev/null +++ b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.test.ts @@ -0,0 +1,444 @@ +/* + * 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 { mockRawDocExistsInNamespace } from './collect_multi_namespace_references.test.mock'; + +import type { DeeplyMockedKeys } from '@kbn/utility-types/target/jest'; +import type { ElasticsearchClient } from 'src/core/server/elasticsearch'; +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + +import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; +import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; +import { SavedObjectsSerializer } from '../../serialization'; +import type { + CollectMultiNamespaceReferencesParams, + SavedObjectsCollectMultiNamespaceReferencesObject, + SavedObjectsCollectMultiNamespaceReferencesOptions, +} from './collect_multi_namespace_references'; +import { collectMultiNamespaceReferences } from './collect_multi_namespace_references'; +import { savedObjectsPointInTimeFinderMock } from './point_in_time_finder.mock'; +import { savedObjectsRepositoryMock } from './repository.mock'; +import { PointInTimeFinder } from './point_in_time_finder'; +import { ISavedObjectsRepository } from './repository'; + +const SPACES = ['default', 'another-space']; +const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 }; + +const MULTI_NAMESPACE_OBJ_TYPE_1 = 'type-a'; +const MULTI_NAMESPACE_OBJ_TYPE_2 = 'type-b'; +const NON_MULTI_NAMESPACE_OBJ_TYPE = 'type-c'; +const MULTI_NAMESPACE_HIDDEN_OBJ_TYPE = 'type-d'; + +beforeEach(() => { + mockRawDocExistsInNamespace.mockReset(); + mockRawDocExistsInNamespace.mockReturnValue(true); // return true by default +}); + +describe('collectMultiNamespaceReferences', () => { + let client: DeeplyMockedKeys; + let savedObjectsMock: jest.Mocked; + let createPointInTimeFinder: jest.MockedFunction< + CollectMultiNamespaceReferencesParams['createPointInTimeFinder'] + >; + let pointInTimeFinder: DeeplyMockedKeys; + + /** Sets up the type registry, saved objects client, etc. and return the full parameters object to be passed to `collectMultiNamespaceReferences` */ + function setup( + objects: SavedObjectsCollectMultiNamespaceReferencesObject[], + options: SavedObjectsCollectMultiNamespaceReferencesOptions = {} + ): CollectMultiNamespaceReferencesParams { + const registry = typeRegistryMock.create(); + registry.isMultiNamespace.mockImplementation( + (type) => + [ + MULTI_NAMESPACE_OBJ_TYPE_1, + MULTI_NAMESPACE_OBJ_TYPE_2, + MULTI_NAMESPACE_HIDDEN_OBJ_TYPE, + ].includes(type) // NON_MULTI_NAMESPACE_TYPE is omitted + ); + registry.isShareable.mockImplementation( + (type) => [MULTI_NAMESPACE_OBJ_TYPE_1, MULTI_NAMESPACE_HIDDEN_OBJ_TYPE].includes(type) // MULTI_NAMESPACE_OBJ_TYPE_2 and NON_MULTI_NAMESPACE_TYPE are omitted + ); + client = elasticsearchClientMock.createElasticsearchClient(); + + const serializer = new SavedObjectsSerializer(registry); + savedObjectsMock = savedObjectsRepositoryMock.create(); + savedObjectsMock.find.mockResolvedValue({ + pit_id: 'foo', + saved_objects: [], + // the rest of these fields don't matter but are included for type safety + total: 0, + page: 1, + per_page: 100, + }); + createPointInTimeFinder = jest.fn(); + createPointInTimeFinder.mockImplementation((params) => { + pointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ savedObjectsMock })(params); + return pointInTimeFinder; + }); + return { + registry, + allowedTypes: [ + MULTI_NAMESPACE_OBJ_TYPE_1, + MULTI_NAMESPACE_OBJ_TYPE_2, + NON_MULTI_NAMESPACE_OBJ_TYPE, + ], // MULTI_NAMESPACE_HIDDEN_TYPE is omitted + client, + serializer, + getIndexForType: (type: string) => `index-for-${type}`, + createPointInTimeFinder, + objects, + options, + }; + } + + /** Mocks the saved objects client so it returns the expected results */ + function mockMgetResults( + ...results: Array<{ + found: boolean; + references?: Array<{ type: string; id: string }>; + }> + ) { + client.mget.mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + docs: results.map((x) => { + const references = + x.references?.map(({ type, id }) => ({ type, id, name: 'ref-name' })) ?? []; + return x.found + ? { + _id: 'doesnt-matter', + _index: 'doesnt-matter', + _source: { + namespaces: SPACES, + references, + }, + ...VERSION_PROPS, + found: true, + } + : { + _id: 'doesnt-matter', + _index: 'doesnt-matter', + found: false, + }; + }), + }) + ); + } + + function mockFindResults(...results: LegacyUrlAlias[]) { + savedObjectsMock.find.mockResolvedValueOnce({ + pit_id: 'foo', + saved_objects: results.map((attributes) => ({ + id: 'doesnt-matter', + type: LEGACY_URL_ALIAS_TYPE, + attributes, + references: [], + score: 0, // doesn't matter + })), + // the rest of these fields don't matter but are included for type safety + total: 0, + page: 1, + per_page: 100, + }); + } + + /** Asserts that mget is called for the given objects */ + function expectMgetArgs( + n: number, + ...objects: SavedObjectsCollectMultiNamespaceReferencesObject[] + ) { + const docs = objects.map(({ type, id }) => expect.objectContaining({ _id: `${type}:${id}` })); + expect(client.mget).toHaveBeenNthCalledWith(n, { body: { docs } }, expect.anything()); + } + + it('returns an empty array if no object args are passed in', async () => { + const params = setup([]); + + const result = await collectMultiNamespaceReferences(params); + expect(client.mget).not.toHaveBeenCalled(); + expect(result.objects).toEqual([]); + }); + + it('excludes args that have unsupported types', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const obj2 = { type: NON_MULTI_NAMESPACE_OBJ_TYPE, id: 'id-2' }; + const obj3 = { type: MULTI_NAMESPACE_HIDDEN_OBJ_TYPE, id: 'id-3' }; + const params = setup([obj1, obj2, obj3]); + mockMgetResults({ found: true }); // results for obj1 + + const result = await collectMultiNamespaceReferences(params); + expect(client.mget).toHaveBeenCalledTimes(1); + expectMgetArgs(1, obj1); // the non-multi-namespace type and the hidden type are excluded + expect(result.objects).toEqual([ + { ...obj1, spaces: SPACES, inboundReferences: [] }, + // even though they are excluded from the cluster call, obj2 and obj3 are included in the results + { ...obj2, spaces: [], inboundReferences: [] }, + { ...obj3, spaces: [], inboundReferences: [] }, + ]); + }); + + it('excludes references that have unsupported types', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const obj2 = { type: NON_MULTI_NAMESPACE_OBJ_TYPE, id: 'id-2' }; + const obj3 = { type: MULTI_NAMESPACE_HIDDEN_OBJ_TYPE, id: 'id-3' }; + const params = setup([obj1]); + mockMgetResults({ found: true, references: [obj2, obj3] }); // results for obj1 + + const result = await collectMultiNamespaceReferences(params); + expect(client.mget).toHaveBeenCalledTimes(1); + expectMgetArgs(1, obj1); + // obj2 and obj3 are not retrieved in a second cluster call + expect(result.objects).toEqual([ + { ...obj1, spaces: SPACES, inboundReferences: [] }, + // obj2 and obj3 are excluded from the results + ]); + }); + + it('handles circular references', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const params = setup([obj1]); + mockMgetResults({ found: true, references: [obj1] }); // results for obj1 + + const result = await collectMultiNamespaceReferences(params); + expect(params.client.mget).toHaveBeenCalledTimes(1); + expectMgetArgs(1, obj1); // obj1 is retrieved once, and it is not retrieved again in a second cluster call + expect(result.objects).toEqual([ + { ...obj1, spaces: SPACES, inboundReferences: [{ ...obj1, name: 'ref-name' }] }, // obj1 reflects the inbound reference to itself + ]); + }); + + it('handles a reference graph more than 20 layers deep (circuit-breaker)', async () => { + const type = MULTI_NAMESPACE_OBJ_TYPE_1; + const params = setup([{ type, id: 'id-1' }]); + for (let i = 1; i < 100; i++) { + mockMgetResults({ found: true, references: [{ type, id: `id-${i + 1}` }] }); + } + + await expect(() => collectMultiNamespaceReferences(params)).rejects.toThrow( + /Exceeded maximum reference graph depth/ + ); + expect(params.client.mget).toHaveBeenCalledTimes(20); + }); + + it('handles multiple inbound references', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const obj2 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-2' }; + const obj3 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-3' }; + const params = setup([obj1, obj2]); + mockMgetResults({ found: true, references: [obj3] }, { found: true, references: [obj3] }); // results for obj1 and obj2 + mockMgetResults({ found: true }); // results for obj3 + + const result = await collectMultiNamespaceReferences(params); + expect(params.client.mget).toHaveBeenCalledTimes(2); + expectMgetArgs(1, obj1, obj2); + expectMgetArgs(2, obj3); // obj3 is retrieved in a second cluster call + expect(result.objects).toEqual([ + { ...obj1, spaces: SPACES, inboundReferences: [] }, + { ...obj2, spaces: SPACES, inboundReferences: [] }, + { + ...obj3, + spaces: SPACES, + inboundReferences: [ + // obj3 reflects both inbound references + { ...obj1, name: 'ref-name' }, + { ...obj2, name: 'ref-name' }, + ], + }, + ]); + }); + + it('handles transitive references', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const obj2 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-2' }; + const obj3 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-3' }; + const params = setup([obj1]); + mockMgetResults({ found: true, references: [obj2] }); // results for obj1 + mockMgetResults({ found: true, references: [obj3] }); // results for obj2 + mockMgetResults({ found: true }); // results for obj3 + + const result = await collectMultiNamespaceReferences(params); + expect(params.client.mget).toHaveBeenCalledTimes(3); + expectMgetArgs(1, obj1); + expectMgetArgs(2, obj2); // obj2 is retrieved in a second cluster call + expectMgetArgs(3, obj3); // obj3 is retrieved in a third cluster call + expect(result.objects).toEqual([ + { ...obj1, spaces: SPACES, inboundReferences: [] }, + { ...obj2, spaces: SPACES, inboundReferences: [{ ...obj1, name: 'ref-name' }] }, // obj2 reflects the inbound reference + { ...obj3, spaces: SPACES, inboundReferences: [{ ...obj2, name: 'ref-name' }] }, // obj3 reflects the inbound reference + ]); + }); + + it('handles missing objects and missing references', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; // found, with missing references to obj4 and obj5 + const obj2 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-2' }; // missing object (found, but doesn't exist in the current space)) + const obj3 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-3' }; // missing object (not found + const obj4 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-4' }; // missing reference (found but doesn't exist in the current space) + const obj5 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-5' }; // missing reference (not found) + const params = setup([obj1, obj2, obj3]); + mockMgetResults({ found: true, references: [obj4, obj5] }, { found: true }, { found: false }); // results for obj1, obj2, and obj3 + mockMgetResults({ found: true }, { found: false }); // results for obj4 and obj5 + mockRawDocExistsInNamespace.mockReturnValueOnce(true); // for obj1 + mockRawDocExistsInNamespace.mockReturnValueOnce(false); // for obj2 + mockRawDocExistsInNamespace.mockReturnValueOnce(false); // for obj4 + + const result = await collectMultiNamespaceReferences(params); + expect(params.client.mget).toHaveBeenCalledTimes(2); + expectMgetArgs(1, obj1, obj2, obj3); + expectMgetArgs(2, obj4, obj5); + expect(mockRawDocExistsInNamespace).toHaveBeenCalledTimes(3); + expect(result.objects).toEqual([ + { ...obj1, spaces: SPACES, inboundReferences: [] }, + { ...obj2, spaces: [], inboundReferences: [], isMissing: true }, + { ...obj3, spaces: [], inboundReferences: [], isMissing: true }, + { ...obj4, spaces: [], inboundReferences: [{ ...obj1, name: 'ref-name' }], isMissing: true }, + { ...obj5, spaces: [], inboundReferences: [{ ...obj1, name: 'ref-name' }], isMissing: true }, + ]); + }); + + it('handles the purpose="updateObjectsSpaces" option', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const obj2 = { type: MULTI_NAMESPACE_OBJ_TYPE_2, id: 'id-2' }; + const obj3 = { type: MULTI_NAMESPACE_OBJ_TYPE_2, id: 'id-3' }; + const params = setup([obj1, obj2], { purpose: 'updateObjectsSpaces' }); + mockMgetResults({ found: true, references: [obj3] }); // results for obj1 + + const result = await collectMultiNamespaceReferences(params); + expect(client.mget).toHaveBeenCalledTimes(1); + expectMgetArgs(1, obj1); // obj2 is excluded + // obj3 is not retrieved in a second cluster call + expect(result.objects).toEqual([ + { ...obj1, spaces: SPACES, inboundReferences: [] }, + // even though it is excluded from the cluster call, obj2 is included in the results + { ...obj2, spaces: [], inboundReferences: [] }, + // obj3 is excluded from the results + ]); + }); + + describe('legacy URL aliases', () => { + it('uses the PointInTimeFinder to search for legacy URL aliases', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const obj2 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-2' }; + const obj3 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-3' }; + const params = setup([obj1, obj2], {}); + mockMgetResults({ found: true, references: [obj3] }, { found: true, references: [] }); // results for obj1 and obj2 + mockMgetResults({ found: true, references: [] }); // results for obj3 + mockFindResults( + // mock search results for four aliases for obj1, and none for obj2 or obj3 + ...[1, 2, 3, 4].map((i) => ({ + sourceId: obj1.id, + targetId: 'doesnt-matter', + targetType: obj1.type, + targetNamespace: `space-${i}`, + })) + ); + + const result = await collectMultiNamespaceReferences(params); + expect(client.mget).toHaveBeenCalledTimes(2); + expectMgetArgs(1, obj1, obj2); + expectMgetArgs(2, obj3); // obj3 is retrieved in a second cluster call + expect(createPointInTimeFinder).toHaveBeenCalledTimes(1); + const kueryFilterArgs = createPointInTimeFinder.mock.calls[0][0].filter.arguments; + expect(kueryFilterArgs).toHaveLength(2); + const typeAndIdFilters = kueryFilterArgs[1].arguments; + expect(typeAndIdFilters).toHaveLength(3); + [obj1, obj2, obj3].forEach(({ type, id }, i) => { + const typeAndIdFilter = typeAndIdFilters[i].arguments; + expect(typeAndIdFilter).toEqual([ + expect.objectContaining({ + arguments: expect.arrayContaining([{ type: 'literal', value: type }]), + }), + expect.objectContaining({ + arguments: expect.arrayContaining([{ type: 'literal', value: id }]), + }), + ]); + }); + expect(pointInTimeFinder.find).toHaveBeenCalledTimes(1); + expect(pointInTimeFinder.close).toHaveBeenCalledTimes(2); + expect(result.objects).toEqual([ + { + ...obj1, + spaces: SPACES, + inboundReferences: [], + spacesWithMatchingAliases: ['space-1', 'space-2', 'space-3', 'space-4'], + }, + { ...obj2, spaces: SPACES, inboundReferences: [] }, + { ...obj3, spaces: SPACES, inboundReferences: [{ ...obj1, name: 'ref-name' }] }, + ]); + }); + + it('does not create a PointInTimeFinder if no objects are passed in', async () => { + const params = setup([]); + + await collectMultiNamespaceReferences(params); + expect(params.createPointInTimeFinder).not.toHaveBeenCalled(); + }); + + it('does not search for objects that have an empty spaces array (the object does not exist, or we are not sure)', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const obj2 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-2' }; + const params = setup([obj1, obj2]); + mockMgetResults({ found: true }, { found: false }); // results for obj1 and obj2 + + await collectMultiNamespaceReferences(params); + expect(createPointInTimeFinder).toHaveBeenCalledTimes(1); + + const kueryFilterArgs = createPointInTimeFinder.mock.calls[0][0].filter.arguments; + expect(kueryFilterArgs).toHaveLength(2); + const typeAndIdFilters = kueryFilterArgs[1].arguments; + expect(typeAndIdFilters).toHaveLength(1); + const typeAndIdFilter = typeAndIdFilters[0].arguments; + expect(typeAndIdFilter).toEqual([ + expect.objectContaining({ + arguments: expect.arrayContaining([{ type: 'literal', value: obj1.type }]), + }), + expect.objectContaining({ + arguments: expect.arrayContaining([{ type: 'literal', value: obj1.id }]), + }), + ]); + expect(pointInTimeFinder.find).toHaveBeenCalledTimes(1); + expect(pointInTimeFinder.close).toHaveBeenCalledTimes(2); + }); + + it('does not search at all if all objects that have an empty spaces array (the object does not exist, or we are not sure)', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const params = setup([obj1]); + mockMgetResults({ found: false }); // results for obj1 + + await collectMultiNamespaceReferences(params); + expect(params.createPointInTimeFinder).not.toHaveBeenCalled(); + }); + + it('handles PointInTimeFinder.find errors', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const params = setup([obj1]); + mockMgetResults({ found: true }); // results for obj1 + savedObjectsMock.find.mockRejectedValue(new Error('Oh no!')); + + await expect(() => collectMultiNamespaceReferences(params)).rejects.toThrow( + 'Failed to retrieve legacy URL aliases: Oh no!' + ); + expect(createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(pointInTimeFinder.find).toHaveBeenCalledTimes(1); + expect(pointInTimeFinder.close).toHaveBeenCalledTimes(2); // we still close the point-in-time, even though the search failed + }); + + it('handles PointInTimeFinder.close errors', async () => { + const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' }; + const params = setup([obj1]); + mockMgetResults({ found: true }); // results for obj1 + savedObjectsMock.closePointInTime.mockRejectedValue(new Error('Oh no!')); + + await expect(() => collectMultiNamespaceReferences(params)).rejects.toThrow( + 'Failed to retrieve legacy URL aliases: Oh no!' + ); + expect(createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(pointInTimeFinder.find).toHaveBeenCalledTimes(1); + expect(pointInTimeFinder.close).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts new file mode 100644 index 0000000000000..43923695f6548 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts @@ -0,0 +1,310 @@ +/* + * 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. + */ + +// @ts-expect-error no ts +import { esKuery } from '../../es_query'; + +import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; +import type { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { SavedObjectsSerializer } from '../../serialization'; +import type { SavedObject, SavedObjectsBaseOptions } from '../../types'; +import { getRootFields } from './included_fields'; +import { getSavedObjectFromSource, rawDocExistsInNamespace } from './internal_utils'; +import type { + ISavedObjectsPointInTimeFinder, + SavedObjectsCreatePointInTimeFinderOptions, +} from './point_in_time_finder'; +import type { RepositoryEsClient } from './repository_es_client'; + +/** + * When we collect an object's outbound references, we will only go a maximum of this many levels deep before we throw an error. + */ +const MAX_REFERENCE_GRAPH_DEPTH = 20; + +/** + * How many aliases to search for per page. This is smaller than the PointInTimeFinder's default of 1000. We specify 100 for the page count + * because this is a relatively unimportant operation, and we want to avoid blocking the Elasticsearch thread pool for longer than + * necessary. + */ +const ALIAS_SEARCH_PER_PAGE = 100; + +/** + * An object to collect references for. It must be a multi-namespace type (in other words, the object type must be registered with the + * `namespaceType: 'multiple'` or `namespaceType: 'multiple-isolated'` option). + * + * Note: if options.purpose is 'updateObjectsSpaces', it must be a shareable type (in other words, the object type must be registered with + * the `namespaceType: 'multiple'`). + * + * @public + */ +export interface SavedObjectsCollectMultiNamespaceReferencesObject { + id: string; + type: string; +} + +/** + * Options for collecting references. + * + * @public + */ +export interface SavedObjectsCollectMultiNamespaceReferencesOptions + extends SavedObjectsBaseOptions { + /** Optional purpose used to determine filtering and authorization checks; default is 'collectMultiNamespaceReferences' */ + purpose?: 'collectMultiNamespaceReferences' | 'updateObjectsSpaces'; +} + +/** + * A returned input object or one of its references, with additional context. + * + * @public + */ +export interface SavedObjectReferenceWithContext { + /** The type of the referenced object */ + type: string; + /** The ID of the referenced object */ + id: string; + /** The space(s) that the referenced object exists in */ + spaces: string[]; + /** + * References to this object; note that this does not contain _all inbound references everywhere for this object_, it only contains + * inbound references for the scope of this operation + */ + inboundReferences: Array<{ + /** The type of the object that has the inbound reference */ + type: string; + /** The ID of the object that has the inbound reference */ + id: string; + /** The name of the inbound reference */ + name: string; + }>; + /** Whether or not this object or reference is missing */ + isMissing?: boolean; + /** The space(s) that legacy URL aliases matching this type/id exist in */ + spacesWithMatchingAliases?: string[]; +} + +/** + * The response when object references are collected. + * + * @public + */ +export interface SavedObjectsCollectMultiNamespaceReferencesResponse { + objects: SavedObjectReferenceWithContext[]; +} + +/** + * Parameters for the collectMultiNamespaceReferences function. + * + * @internal + */ +export interface CollectMultiNamespaceReferencesParams { + registry: ISavedObjectTypeRegistry; + allowedTypes: string[]; + client: RepositoryEsClient; + serializer: SavedObjectsSerializer; + getIndexForType: (type: string) => string; + createPointInTimeFinder: ( + findOptions: SavedObjectsCreatePointInTimeFinderOptions + ) => ISavedObjectsPointInTimeFinder; + objects: SavedObjectsCollectMultiNamespaceReferencesObject[]; + options?: SavedObjectsCollectMultiNamespaceReferencesOptions; +} + +/** + * Gets all references and transitive references of the given objects. Ignores any object and/or reference that is not a multi-namespace + * type. + */ +export async function collectMultiNamespaceReferences( + params: CollectMultiNamespaceReferencesParams +): Promise { + const { createPointInTimeFinder, objects } = params; + if (!objects.length) { + return { objects: [] }; + } + + const { objectMap, inboundReferencesMap } = await getObjectsAndReferences(params); + const objectsWithContext = Array.from( + inboundReferencesMap.entries() + ).map(([referenceKey, referenceVal]) => { + const inboundReferences = Array.from(referenceVal.entries()).map(([objectKey, name]) => { + const { type, id } = parseKey(objectKey); + return { type, id, name }; + }); + const { type, id } = parseKey(referenceKey); + const object = objectMap.get(referenceKey); + const spaces = object?.namespaces ?? []; + return { type, id, spaces, inboundReferences, ...(object === null && { isMissing: true }) }; + }); + + const aliasesMap = await checkLegacyUrlAliases(createPointInTimeFinder, objectsWithContext); + const results = objectsWithContext.map((obj) => { + const key = getKey(obj); + const val = aliasesMap.get(key); + const spacesWithMatchingAliases = val && Array.from(val); + return { ...obj, spacesWithMatchingAliases }; + }); + + return { + objects: results, + }; +} + +/** + * Recursively fetches objects and their references, returning a map of the retrieved objects and a map of all inbound references. + */ +async function getObjectsAndReferences({ + registry, + allowedTypes, + client, + serializer, + getIndexForType, + objects, + options = {}, +}: CollectMultiNamespaceReferencesParams) { + const { namespace, purpose } = options; + const inboundReferencesMap = objects.reduce( + // Add the input objects to the references map so they are returned with the results, even if they have no inbound references + (acc, cur) => acc.set(getKey(cur), new Map()), + new Map>() + ); + const objectMap = new Map(); + + const rootFields = getRootFields(); + const makeBulkGetDocs = (objectsToGet: SavedObjectsCollectMultiNamespaceReferencesObject[]) => + objectsToGet.map(({ type, id }) => ({ + _id: serializer.generateRawId(undefined, type, id), + _index: getIndexForType(type), + _source: rootFields, // Optimized to only retrieve root fields (ignoring type-specific fields) + })); + const validObjectTypesFilter = ({ type }: SavedObjectsCollectMultiNamespaceReferencesObject) => + allowedTypes.includes(type) && + (purpose === 'updateObjectsSpaces' + ? registry.isShareable(type) + : registry.isMultiNamespace(type)); + + let bulkGetObjects = objects.filter(validObjectTypesFilter); + let count = 0; // this is a circuit-breaker to ensure we don't hog too many resources; we should never have an object graph this deep + while (bulkGetObjects.length) { + if (count >= MAX_REFERENCE_GRAPH_DEPTH) { + throw new Error( + `Exceeded maximum reference graph depth of ${MAX_REFERENCE_GRAPH_DEPTH} objects!` + ); + } + const bulkGetResponse = await client.mget( + { body: { docs: makeBulkGetDocs(bulkGetObjects) } }, + { ignore: [404] } + ); + const newObjectsToGet = new Set(); + for (let i = 0; i < bulkGetObjects.length; i++) { + // For every element in bulkGetObjects, there should be a matching element in bulkGetResponse.body.docs + const { type, id } = bulkGetObjects[i]; + const objectKey = getKey({ type, id }); + const doc = bulkGetResponse.body.docs[i]; + // @ts-expect-error MultiGetHit._source is optional + if (!doc.found || !rawDocExistsInNamespace(registry, doc, namespace)) { + objectMap.set(objectKey, null); + continue; + } + // @ts-expect-error MultiGetHit._source is optional + const object = getSavedObjectFromSource(registry, type, id, doc); + objectMap.set(objectKey, object); + for (const reference of object.references) { + if (!validObjectTypesFilter(reference)) { + continue; + } + const referenceKey = getKey(reference); + const referenceVal = inboundReferencesMap.get(referenceKey) ?? new Map(); + if (!referenceVal.has(objectKey)) { + inboundReferencesMap.set(referenceKey, referenceVal.set(objectKey, reference.name)); + } + if (!objectMap.has(referenceKey)) { + newObjectsToGet.add(referenceKey); + } + } + } + bulkGetObjects = Array.from(newObjectsToGet).map((key) => parseKey(key)); + count++; + } + + return { objectMap, inboundReferencesMap }; +} + +/** + * Fetches all legacy URL aliases that match the given objects, returning a map of the matching aliases and what space(s) they exist in. + */ +async function checkLegacyUrlAliases( + createPointInTimeFinder: ( + findOptions: SavedObjectsCreatePointInTimeFinderOptions + ) => ISavedObjectsPointInTimeFinder, + objects: SavedObjectReferenceWithContext[] +) { + const filteredObjects = objects.filter(({ spaces }) => spaces.length !== 0); + if (!filteredObjects.length) { + return new Map>(); + } + const filter = createAliasKueryFilter(filteredObjects); + const finder = createPointInTimeFinder({ + type: LEGACY_URL_ALIAS_TYPE, + perPage: ALIAS_SEARCH_PER_PAGE, + filter, + }); + const aliasesMap = new Map>(); + let error: Error | undefined; + try { + for await (const { saved_objects: savedObjects } of finder.find()) { + for (const alias of savedObjects) { + const { sourceId, targetType, targetNamespace } = alias.attributes; + const key = getKey({ type: targetType, id: sourceId }); + const val = aliasesMap.get(key) ?? new Set(); + val.add(targetNamespace); + aliasesMap.set(key, val); + } + } + } catch (e) { + error = e; + } + + try { + await finder.close(); + } catch (e) { + if (!error) { + error = e; + } + } + + if (error) { + throw new Error(`Failed to retrieve legacy URL aliases: ${error.message}`); + } + return aliasesMap; +} + +function createAliasKueryFilter(objects: SavedObjectReferenceWithContext[]) { + const { buildNode } = esKuery.nodeTypes.function; + const kueryNodes = objects.reduce((acc, { type, id }) => { + const match1 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.attributes.targetType`, type); + const match2 = buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.attributes.sourceId`, id); + acc.push(buildNode('and', [match1, match2])); + return acc; + }, []); + return buildNode('and', [ + buildNode('not', buildNode('is', `${LEGACY_URL_ALIAS_TYPE}.attributes.disabled`, true)), // ignore aliases that have been disabled + buildNode('or', kueryNodes), + ]); +} + +/** Takes an object with a `type` and `id` field and returns a key string */ +function getKey({ type, id }: { type: string; id: string }) { + return `${type}:${id}`; +} + +/** Parses a 'type:id' key string and returns an object with a `type` field and an `id` field */ +function parseKey(key: string) { + const type = key.slice(0, key.indexOf(':')); + const id = key.slice(type.length + 1); + return { type, id }; +} diff --git a/src/core/server/saved_objects/service/lib/included_fields.test.ts b/src/core/server/saved_objects/service/lib/included_fields.test.ts index 334cda91129f3..51c431b1c6b3b 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.test.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.test.ts @@ -6,125 +6,63 @@ * Side Public License, v 1. */ -import { includedFields } from './included_fields'; +import { getRootFields, includedFields } from './included_fields'; -const BASE_FIELD_COUNT = 10; +describe('getRootFields', () => { + it('returns copy of root fields', () => { + const fields = getRootFields(); + expect(fields).toMatchInlineSnapshot(` + Array [ + "namespace", + "namespaces", + "type", + "references", + "migrationVersion", + "coreMigrationVersion", + "updated_at", + "originId", + ] + `); + }); +}); describe('includedFields', () => { + const rootFields = getRootFields(); + it('returns undefined if fields are not provided', () => { expect(includedFields()).toBe(undefined); }); - it('accepts type string', () => { + it('accepts type and field as string', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(BASE_FIELD_COUNT); - expect(fields).toContain('type'); + expect(fields).toEqual(['config.foo', ...rootFields, 'foo']); }); - it('accepts type as string array', () => { + it('accepts type as array and field as string', () => { const fields = includedFields(['config', 'secret'], 'foo'); - expect(fields).toMatchInlineSnapshot(` -Array [ - "config.foo", - "secret.foo", - "namespace", - "namespaces", - "type", - "references", - "migrationVersion", - "coreMigrationVersion", - "updated_at", - "originId", - "foo", -] -`); - }); - - it('accepts field as string', () => { - const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(BASE_FIELD_COUNT); - expect(fields).toContain('config.foo'); + expect(fields).toEqual(['config.foo', 'secret.foo', ...rootFields, 'foo']); }); - it('accepts fields as an array', () => { + it('accepts type as string and field as array', () => { const fields = includedFields('config', ['foo', 'bar']); - - expect(fields).toHaveLength(BASE_FIELD_COUNT + 2); - expect(fields).toContain('config.foo'); - expect(fields).toContain('config.bar'); + expect(fields).toEqual(['config.foo', 'config.bar', ...rootFields, 'foo', 'bar']); }); - it('accepts type as string array and fields as string array', () => { + it('accepts type as array and field as array', () => { const fields = includedFields(['config', 'secret'], ['foo', 'bar']); - expect(fields).toMatchInlineSnapshot(` -Array [ - "config.foo", - "config.bar", - "secret.foo", - "secret.bar", - "namespace", - "namespaces", - "type", - "references", - "migrationVersion", - "coreMigrationVersion", - "updated_at", - "originId", - "foo", - "bar", -] -`); - }); - - it('includes namespace', () => { - const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(BASE_FIELD_COUNT); - expect(fields).toContain('namespace'); - }); - - it('includes namespaces', () => { - const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(BASE_FIELD_COUNT); - expect(fields).toContain('namespaces'); - }); - - it('includes references', () => { - const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(BASE_FIELD_COUNT); - expect(fields).toContain('references'); - }); - - it('includes migrationVersion', () => { - const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(BASE_FIELD_COUNT); - expect(fields).toContain('migrationVersion'); - }); - - it('includes updated_at', () => { - const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(BASE_FIELD_COUNT); - expect(fields).toContain('updated_at'); - }); - - it('includes originId', () => { - const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(BASE_FIELD_COUNT); - expect(fields).toContain('originId'); + expect(fields).toEqual([ + 'config.foo', + 'config.bar', + 'secret.foo', + 'secret.bar', + ...rootFields, + 'foo', + 'bar', + ]); }); it('uses wildcard when type is not provided', () => { const fields = includedFields(undefined, 'foo'); - expect(fields).toHaveLength(BASE_FIELD_COUNT); - expect(fields).toContain('*.foo'); - }); - - describe('v5 compatibility', () => { - it('includes legacy field path', () => { - const fields = includedFields('config', ['foo', 'bar']); - - expect(fields).toHaveLength(BASE_FIELD_COUNT + 2); - expect(fields).toContain('foo'); - expect(fields).toContain('bar'); - }); + expect(fields).toEqual(['*.foo', ...rootFields, 'foo']); }); }); diff --git a/src/core/server/saved_objects/service/lib/included_fields.ts b/src/core/server/saved_objects/service/lib/included_fields.ts index cef83f103ec53..9613d8f6a4a41 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.ts @@ -9,6 +9,22 @@ function toArray(value: string | string[]): string[] { return typeof value === 'string' ? [value] : value; } + +const ROOT_FIELDS = [ + 'namespace', + 'namespaces', + 'type', + 'references', + 'migrationVersion', + 'coreMigrationVersion', + 'updated_at', + 'originId', +]; + +export function getRootFields() { + return [...ROOT_FIELDS]; +} + /** * Provides an array of paths for ES source filtering */ @@ -28,13 +44,6 @@ export function includedFields( .reduce((acc: string[], t) => { return [...acc, ...sourceFields.map((f) => `${t}.${f}`)]; }, []) - .concat('namespace') - .concat('namespaces') - .concat('type') - .concat('references') - .concat('migrationVersion') - .concat('coreMigrationVersion') - .concat('updated_at') - .concat('originId') + .concat(ROOT_FIELDS) .concat(fields); // v5 compatibility } diff --git a/src/core/server/saved_objects/service/lib/index.ts b/src/core/server/saved_objects/service/lib/index.ts index 09bce81b14c39..661d04b8a0b2a 100644 --- a/src/core/server/saved_objects/service/lib/index.ts +++ b/src/core/server/saved_objects/service/lib/index.ts @@ -27,3 +27,17 @@ export type { export { SavedObjectsErrorHelpers } from './errors'; export { SavedObjectsUtils } from './utils'; + +export type { + SavedObjectsCollectMultiNamespaceReferencesObject, + SavedObjectsCollectMultiNamespaceReferencesOptions, + SavedObjectReferenceWithContext, + SavedObjectsCollectMultiNamespaceReferencesResponse, +} from './collect_multi_namespace_references'; + +export type { + SavedObjectsUpdateObjectsSpacesObject, + SavedObjectsUpdateObjectsSpacesOptions, + SavedObjectsUpdateObjectsSpacesResponse, + SavedObjectsUpdateObjectsSpacesResponseObject, +} from './update_objects_spaces'; 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 new file mode 100644 index 0000000000000..d1fd067990f07 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/internal_utils.test.ts @@ -0,0 +1,243 @@ +/* + * 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 { typeRegistryMock } from '../../saved_objects_type_registry.mock'; +import type { SavedObjectsRawDoc } from '../../serialization'; +import { encodeHitVersion } from '../../version'; +import { + getBulkOperationError, + getSavedObjectFromSource, + rawDocExistsInNamespace, +} from './internal_utils'; +import { ALL_NAMESPACES_STRING } from './utils'; + +describe('#getBulkOperationError', () => { + const type = 'obj-type'; + const id = 'obj-id'; + + it('returns index not found error', () => { + const rawResponse = { + status: 404, + error: { type: 'index_not_found_exception', reason: 'some-reason', index: 'some-index' }, + }; + + const result = getBulkOperationError(type, id, rawResponse); + expect(result).toEqual({ + error: 'Internal Server Error', + message: 'An internal server error occurred', // TODO: this error payload is not very helpful to consumers, can we change it? + statusCode: 500, + }); + }); + + it('returns generic not found error', () => { + const rawResponse = { + status: 404, + error: { type: 'anything', reason: 'some-reason', index: 'some-index' }, + }; + + const result = getBulkOperationError(type, id, rawResponse); + expect(result).toEqual({ + error: 'Not Found', + message: `Saved object [${type}/${id}] not found`, + statusCode: 404, + }); + }); + + it('returns conflict error', () => { + const rawResponse = { + status: 409, + error: { type: 'anything', reason: 'some-reason', index: 'some-index' }, + }; + + const result = getBulkOperationError(type, id, rawResponse); + expect(result).toEqual({ + error: 'Conflict', + message: `Saved object [${type}/${id}] conflict`, + statusCode: 409, + }); + }); + + it('returns an unexpected result error', () => { + const rawResponse = { + status: 123, // any status + error: { type: 'anything', reason: 'some-reason', index: 'some-index' }, + }; + + const result = getBulkOperationError(type, id, rawResponse); + expect(result).toEqual({ + error: 'Internal Server Error', + message: `Unexpected bulk response [${rawResponse.status}] ${rawResponse.error.type}: ${rawResponse.error.reason}`, + statusCode: 500, + }); + }); +}); + +describe('#getSavedObjectFromSource', () => { + const NAMESPACE_AGNOSTIC_TYPE = 'agnostic-type'; + const NON_NAMESPACE_AGNOSTIC_TYPE = 'other-type'; + + const registry = typeRegistryMock.create(); + registry.isNamespaceAgnostic.mockImplementation((type) => type === NAMESPACE_AGNOSTIC_TYPE); + + const id = 'obj-id'; + const _seq_no = 1; + const _primary_term = 1; + const attributes = { foo: 'bar' }; + const references = [{ type: 'ref-type', id: 'ref-id', name: 'ref-name' }]; + const migrationVersion = { foo: 'migrationVersion' }; + const coreMigrationVersion = 'coreMigrationVersion'; + const originId = 'originId'; + // eslint-disable-next-line @typescript-eslint/naming-convention + const updated_at = 'updatedAt'; + + function createRawDoc( + type: string, + namespaceAttrs?: { namespace?: string; namespaces?: string[] } + ) { + return { + // other fields exist on the raw document, but they are not relevant to these test cases + _seq_no, + _primary_term, + _source: { + type, + [type]: attributes, + references, + migrationVersion, + coreMigrationVersion, + originId, + updated_at, + ...namespaceAttrs, + }, + }; + } + + it('returns object with expected attributes', () => { + const type = 'any-type'; + const doc = createRawDoc(type); + + const result = getSavedObjectFromSource(registry, type, id, doc); + expect(result).toEqual({ + attributes, + coreMigrationVersion, + id, + migrationVersion, + namespaces: expect.anything(), // see specific test cases below + originId, + references, + type, + updated_at, + version: encodeHitVersion(doc), + }); + }); + + it('returns object with empty namespaces array when type is namespace-agnostic', () => { + const type = NAMESPACE_AGNOSTIC_TYPE; + const doc = createRawDoc(type); + + const result = getSavedObjectFromSource(registry, type, id, doc); + expect(result).toEqual(expect.objectContaining({ namespaces: [] })); + }); + + it('returns object with namespaces when type is not namespace-agnostic and namespaces array is defined', () => { + const type = NON_NAMESPACE_AGNOSTIC_TYPE; + const namespaces = ['foo-ns', 'bar-ns']; + const doc = createRawDoc(type, { namespaces }); + + const result = getSavedObjectFromSource(registry, type, id, doc); + expect(result).toEqual(expect.objectContaining({ namespaces })); + }); + + it('derives namespaces from namespace attribute when type is not namespace-agnostic and namespaces array is not defined', () => { + // Deriving namespaces from the namespace attribute is an implementation detail of SavedObjectsUtils.namespaceIdToString(). + // However, these test cases assertions are written out anyway for clarity. + const type = NON_NAMESPACE_AGNOSTIC_TYPE; + const doc1 = createRawDoc(type, { namespace: undefined }); + const doc2 = createRawDoc(type, { namespace: 'foo-ns' }); + + const result1 = getSavedObjectFromSource(registry, type, id, doc1); + const result2 = getSavedObjectFromSource(registry, type, id, doc2); + expect(result1).toEqual(expect.objectContaining({ namespaces: ['default'] })); + expect(result2).toEqual(expect.objectContaining({ namespaces: ['foo-ns'] })); + }); +}); + +describe('#rawDocExistsInNamespace', () => { + const SINGLE_NAMESPACE_TYPE = 'single-type'; + const MULTI_NAMESPACE_TYPE = 'multi-type'; + const NAMESPACE_AGNOSTIC_TYPE = 'agnostic-type'; + + const registry = typeRegistryMock.create(); + registry.isSingleNamespace.mockImplementation((type) => type === SINGLE_NAMESPACE_TYPE); + registry.isMultiNamespace.mockImplementation((type) => type === MULTI_NAMESPACE_TYPE); + registry.isNamespaceAgnostic.mockImplementation((type) => type === NAMESPACE_AGNOSTIC_TYPE); + + function createRawDoc( + type: string, + namespaceAttrs: { namespace?: string; namespaces?: string[] } + ) { + return { + // other fields exist on the raw document, but they are not relevant to these test cases + _source: { + type, + ...namespaceAttrs, + }, + } as SavedObjectsRawDoc; + } + + describe('single-namespace type', () => { + it('returns true regardless of namespace or namespaces fields', () => { + // Technically, a single-namespace type does not exist in a space unless it has a namespace prefix in its raw ID and a matching + // 'namespace' field. However, historically we have not enforced the latter, we have just relied on searching for and deserializing + // documents with the correct namespace prefix. We may revisit this in the future. + const doc1 = createRawDoc(SINGLE_NAMESPACE_TYPE, { namespace: 'some-space' }); // the namespace field is ignored + const doc2 = createRawDoc(SINGLE_NAMESPACE_TYPE, { namespaces: ['some-space'] }); // the namespaces field is ignored + expect(rawDocExistsInNamespace(registry, doc1, undefined)).toBe(true); + expect(rawDocExistsInNamespace(registry, doc1, 'some-space')).toBe(true); + expect(rawDocExistsInNamespace(registry, doc1, 'other-space')).toBe(true); + expect(rawDocExistsInNamespace(registry, doc2, undefined)).toBe(true); + expect(rawDocExistsInNamespace(registry, doc2, 'some-space')).toBe(true); + expect(rawDocExistsInNamespace(registry, doc2, 'other-space')).toBe(true); + }); + }); + + describe('multi-namespace type', () => { + const docInDefaultSpace = createRawDoc(MULTI_NAMESPACE_TYPE, { namespaces: ['default'] }); + const docInSomeSpace = createRawDoc(MULTI_NAMESPACE_TYPE, { namespaces: ['some-space'] }); + const docInAllSpaces = createRawDoc(MULTI_NAMESPACE_TYPE, { + namespaces: [ALL_NAMESPACES_STRING], + }); + const docInNoSpace = createRawDoc(MULTI_NAMESPACE_TYPE, { namespaces: [] }); + + it('returns true when the document namespaces matches', () => { + expect(rawDocExistsInNamespace(registry, docInDefaultSpace, undefined)).toBe(true); + expect(rawDocExistsInNamespace(registry, docInAllSpaces, undefined)).toBe(true); + expect(rawDocExistsInNamespace(registry, docInSomeSpace, 'some-space')).toBe(true); + expect(rawDocExistsInNamespace(registry, docInAllSpaces, 'some-space')).toBe(true); + expect(rawDocExistsInNamespace(registry, docInAllSpaces, 'other-space')).toBe(true); + }); + + it('returns false when the document namespace does not match', () => { + expect(rawDocExistsInNamespace(registry, docInDefaultSpace, 'other-space')).toBe(false); + expect(rawDocExistsInNamespace(registry, docInSomeSpace, 'other-space')).toBe(false); + expect(rawDocExistsInNamespace(registry, docInNoSpace, 'other-space')).toBe(false); + }); + }); + + describe('namespace-agnostic type', () => { + it('returns true regardless of namespace or namespaces fields', () => { + const doc1 = createRawDoc(NAMESPACE_AGNOSTIC_TYPE, { namespace: 'some-space' }); // the namespace field is ignored + const doc2 = createRawDoc(NAMESPACE_AGNOSTIC_TYPE, { namespaces: ['some-space'] }); // the namespaces field is ignored + expect(rawDocExistsInNamespace(registry, doc1, undefined)).toBe(true); + expect(rawDocExistsInNamespace(registry, doc1, 'some-space')).toBe(true); + expect(rawDocExistsInNamespace(registry, doc1, 'other-space')).toBe(true); + expect(rawDocExistsInNamespace(registry, doc2, undefined)).toBe(true); + expect(rawDocExistsInNamespace(registry, doc2, 'some-space')).toBe(true); + expect(rawDocExistsInNamespace(registry, doc2, 'other-space')).toBe(true); + }); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/internal_utils.ts b/src/core/server/saved_objects/service/lib/internal_utils.ts new file mode 100644 index 0000000000000..feaaea15649c7 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/internal_utils.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Payload } from '@hapi/boom'; +import type { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '../../serialization'; +import type { SavedObject } from '../../types'; +import { decodeRequestVersion, encodeHitVersion } from '../../version'; +import { SavedObjectsErrorHelpers } from './errors'; +import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from './utils'; + +/** + * Checks the raw response of a bulk operation and returns an error if necessary. + * + * @param type + * @param id + * @param rawResponse + * + * @internal + */ +export function getBulkOperationError( + type: string, + id: string, + rawResponse: { + status: number; + error?: { type: string; reason: string; index: string }; + // Other fields are present on a bulk operation result but they are irrelevant for this function + } +): Payload | undefined { + const { status, error } = rawResponse; + if (error) { + switch (status) { + case 404: + return error.type === 'index_not_found_exception' + ? SavedObjectsErrorHelpers.createIndexAliasNotFoundError(error.index).output.payload + : SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload; + case 409: + return SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; + default: + return { + error: 'Internal Server Error', + message: `Unexpected bulk response [${status}] ${error.type}: ${error.reason}`, + statusCode: 500, + }; + } + } +} + +/** + * Returns an object with the expected version properties. This facilitates Elasticsearch's Optimistic Concurrency Control. + * + * @param version Optional version specified by the consumer. + * @param document Optional existing document that was obtained in a preflight operation. + * + * @internal + */ +export function getExpectedVersionProperties(version?: string, document?: SavedObjectsRawDoc) { + if (version) { + return decodeRequestVersion(version); + } else if (document) { + return { + if_seq_no: document._seq_no, + if_primary_term: document._primary_term, + }; + } + return {}; +} + +/** + * Gets a saved object from a raw ES document. + * + * @param registry + * @param type + * @param id + * @param doc + * + * @internal + */ +export function getSavedObjectFromSource( + registry: ISavedObjectTypeRegistry, + 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 (!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, + coreMigrationVersion: doc._source.coreMigrationVersion, + }; +} + +/** + * Check to ensure that a raw document exists in a namespace. If the document is not a multi-namespace type, then this returns `true` as + * we rely on the guarantees of the document ID format. If the document is a multi-namespace type, this checks to ensure that the + * document's `namespaces` value includes the string representation of the given namespace. + * + * WARNING: This should only be used for documents that were retrieved from Elasticsearch. Otherwise, the guarantees of the document ID + * format mentioned above do not apply. + * + * @param registry + * @param raw + * @param namespace + */ +export function rawDocExistsInNamespace( + registry: ISavedObjectTypeRegistry, + raw: SavedObjectsRawDoc, + namespace: string | undefined +) { + const rawDocType = raw._source.type; + + // if the type is namespace isolated, or namespace agnostic, we can continue to rely on the guarantees + // of the document ID format and don't need to check this + if (!registry.isMultiNamespace(rawDocType)) { + return true; + } + + const namespaces = raw._source.namespaces; + const existsInNamespace = + namespaces?.includes(SavedObjectsUtils.namespaceIdToString(namespace)) || + namespaces?.includes(ALL_NAMESPACES_STRING); + return existsInNamespace ?? false; +} diff --git a/src/core/server/saved_objects/service/lib/point_in_time_finder.ts b/src/core/server/saved_objects/service/lib/point_in_time_finder.ts index 9a8dcceafebb2..f0ed943c585e5 100644 --- a/src/core/server/saved_objects/service/lib/point_in_time_finder.ts +++ b/src/core/server/saved_objects/service/lib/point_in_time_finder.ts @@ -39,14 +39,14 @@ export interface PointInTimeFinderDependencies } /** @public */ -export interface ISavedObjectsPointInTimeFinder { +export interface ISavedObjectsPointInTimeFinder { /** * An async generator which wraps calls to `savedObjectsClient.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` size. */ - find: () => AsyncGenerator; + find: () => AsyncGenerator>; /** * Closes the Point-In-Time associated with this finder instance. * @@ -63,7 +63,8 @@ export interface ISavedObjectsPointInTimeFinder { /** * @internal */ -export class PointInTimeFinder implements ISavedObjectsPointInTimeFinder { +export class PointInTimeFinder + implements ISavedObjectsPointInTimeFinder { readonly #log: Logger; readonly #client: PointInTimeFinderClient; readonly #findOptions: SavedObjectsFindOptions; @@ -162,7 +163,7 @@ export class PointInTimeFinder implements ISavedObjectsPointInTimeFinder { searchAfter?: estypes.Id[]; }) { try { - return await this.#client.find({ + return await this.#client.find({ // Sort fields are required to use searchAfter, so we set some defaults here sortField: 'updated_at', sortOrder: 'desc', 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 a2092e0571808..0e1426a58f8ae 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -24,11 +24,11 @@ const create = () => { openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), resolve: jest.fn(), update: jest.fn(), - addToNamespaces: jest.fn(), - deleteFromNamespaces: jest.fn(), deleteByNamespace: jest.fn(), incrementCounter: jest.fn(), removeReferencesTo: jest.fn(), + collectMultiNamespaceReferences: jest.fn(), + updateObjectsSpaces: jest.fn(), }; mock.createPointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ 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 33754d0ad9661..22c40a547f419 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -6,7 +6,12 @@ * Side Public License, v 1. */ -import { pointInTimeFinderMock } from './repository.test.mock'; +import { + pointInTimeFinderMock, + mockCollectMultiNamespaceReferences, + mockGetBulkOperationError, + mockUpdateObjectsSpaces, +} from './repository.test.mock'; import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; @@ -67,9 +72,9 @@ describe('SavedObjectsRepository', () => { * This type has namespaceType: 'multiple-isolated'. * * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is NOT shareable - * across namespaces. This distinction only matters when using the `addToNamespaces` and `deleteFromNamespaces` APIs, or when using the - * `initialNamespaces` argument with the `create` and `bulkCreate` APIs. Those allow you to define or change what namespaces an object - * exists in. + * across namespaces. This distinction only matters when using the `collectMultiNamespaceReferences` or `updateObjectsSpaces` APIs, or + * when using the `initialNamespaces` argument with the `create` and `bulkCreate` APIs. Those allow you to define or change what + * namespaces an object exists in. * * In a nutshell, this type is more restrictive than `MULTI_NAMESPACE_TYPE`, so we use `MULTI_NAMESPACE_ISOLATED_TYPE` for any test cases * where `MULTI_NAMESPACE_TYPE` would also satisfy the test case. @@ -295,164 +300,6 @@ describe('SavedObjectsRepository', () => { references: [{ name: 'search_0', type: 'search', id: '123' }], }); - describe('#addToNamespaces', () => { - const id = 'some-id'; - const type = MULTI_NAMESPACE_TYPE; - const currentNs1 = 'default'; - const currentNs2 = 'foo-namespace'; - const newNs1 = 'bar-namespace'; - const newNs2 = 'baz-namespace'; - - const mockGetResponse = (type, id) => { - // mock a document that exists in two namespaces - const mockResponse = getMockGetResponse({ type, id }); - mockResponse._source.namespaces = [currentNs1, currentNs2]; - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) - ); - }; - - const addToNamespacesSuccess = async (type, id, namespaces, options) => { - mockGetResponse(type, id); - client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'updated', - }) - ); - const result = await savedObjectsRepository.addToNamespaces(type, id, namespaces, options); - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.update).toHaveBeenCalledTimes(1); - return result; - }; - - describe('client calls', () => { - it(`should use ES get action then update action`, async () => { - await addToNamespacesSuccess(type, id, [newNs1, newNs2]); - }); - - it(`defaults to the version of the existing document`, async () => { - await addToNamespacesSuccess(type, id, [newNs1, newNs2]); - const versionProperties = { - if_seq_no: mockVersionProps._seq_no, - if_primary_term: mockVersionProps._primary_term, - }; - expect(client.update).toHaveBeenCalledWith( - expect.objectContaining(versionProperties), - expect.anything() - ); - }); - - it(`accepts version`, async () => { - await addToNamespacesSuccess(type, id, [newNs1, newNs2], { - version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), - }); - expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ if_seq_no: 100, if_primary_term: 200 }), - expect.anything() - ); - }); - - it(`defaults to a refresh setting of wait_for`, async () => { - await addToNamespacesSuccess(type, id, [newNs1, newNs2]); - expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ refresh: 'wait_for' }), - expect.anything() - ); - }); - }); - - describe('errors', () => { - const expectNotFoundError = async (type, id, namespaces, options) => { - await expect( - savedObjectsRepository.addToNamespaces(type, id, namespaces, options) - ).rejects.toThrowError(createGenericNotFoundError(type, id)); - }; - const expectBadRequestError = async (type, id, namespaces, message) => { - await expect( - savedObjectsRepository.addToNamespaces(type, id, namespaces) - ).rejects.toThrowError(createBadRequestError(message)); - }; - - it(`throws when type is invalid`, async () => { - await expectNotFoundError('unknownType', id, [newNs1, newNs2]); - expect(client.update).not.toHaveBeenCalled(); - }); - - it(`throws when type is hidden`, async () => { - await expectNotFoundError(HIDDEN_TYPE, id, [newNs1, newNs2]); - expect(client.update).not.toHaveBeenCalled(); - }); - - it(`throws when type is not shareable`, async () => { - const test = async (type) => { - const message = `${type} doesn't support multiple namespaces`; - await expectBadRequestError(type, id, [newNs1, newNs2], message); - expect(client.update).not.toHaveBeenCalled(); - }; - await test('index-pattern'); - await test(MULTI_NAMESPACE_ISOLATED_TYPE); - await test(NAMESPACE_AGNOSTIC_TYPE); - }); - - it(`throws when namespaces is an empty array`, async () => { - const test = async (namespaces) => { - const message = 'namespaces must be a non-empty array of strings'; - await expectBadRequestError(type, id, namespaces, message); - expect(client.update).not.toHaveBeenCalled(); - }; - await test([]); - }); - - it(`throws when ES is unable to find the document during get`, async () => { - client.get.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) - ); - await expectNotFoundError(type, id, [newNs1, newNs2]); - expect(client.get).toHaveBeenCalledTimes(1); - }); - - it(`throws when ES is unable to find the index during get`, async () => { - client.get.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) - ); - await expectNotFoundError(type, id, [newNs1, newNs2]); - expect(client.get).toHaveBeenCalledTimes(1); - }); - - it(`throws when the document exists, but not in this namespace`, async () => { - mockGetResponse(type, id); - await expectNotFoundError(type, id, [newNs1, newNs2], { - namespace: 'some-other-namespace', - }); - expect(client.get).toHaveBeenCalledTimes(1); - }); - - it(`throws when ES is unable to find the document during update`, async () => { - mockGetResponse(type, id); - client.update.mockResolvedValue( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) - ); - await expectNotFoundError(type, id, [newNs1, newNs2]); - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.update).toHaveBeenCalledTimes(1); - }); - }); - - describe('returns', () => { - it(`returns all existing and new namespaces on success`, async () => { - const result = await addToNamespacesSuccess(type, id, [newNs1, newNs2]); - expect(result).toEqual({ namespaces: [currentNs1, currentNs2, newNs1, newNs2] }); - }); - - it(`succeeds when adding existing namespaces`, async () => { - const result = await addToNamespacesSuccess(type, id, [currentNs1]); - expect(result).toEqual({ namespaces: [currentNs1, currentNs2] }); - }); - }); - }); - describe('#bulkCreate', () => { const obj1 = { type: 'config', @@ -757,6 +604,10 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { + afterEach(() => { + mockGetBulkOperationError.mockReset(); + }); + const obj3 = { type: 'dashboard', id: 'three', @@ -764,11 +615,13 @@ describe('SavedObjectsRepository', () => { references: [{ name: 'ref_0', type: 'test', id: '2' }], }; - const bulkCreateError = async (obj, esError, expectedError) => { + const bulkCreateError = async (obj, isBulkError, expectedErrorResult) => { let response; - if (esError) { + if (isBulkError) { + // mock the bulk error for only the second object + mockGetBulkOperationError.mockReturnValueOnce(undefined); + mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error); response = getMockBulkCreateResponse([obj1, obj, obj2]); - response.items[1].create = { error: esError }; } else { response = getMockBulkCreateResponse([obj1, obj2]); } @@ -779,14 +632,14 @@ describe('SavedObjectsRepository', () => { const objects = [obj1, obj, obj2]; const result = await savedObjectsRepository.bulkCreate(objects); expect(client.bulk).toHaveBeenCalled(); - const objCall = esError ? expectObjArgs(obj) : []; + const objCall = isBulkError ? expectObjArgs(obj) : []; const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() ); expect(result).toEqual({ - saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], + saved_objects: [expectSuccess(obj1), expectedErrorResult, expectSuccess(obj2)], }); }; @@ -878,25 +731,9 @@ describe('SavedObjectsRepository', () => { }); }); - it(`returns error when there is a version conflict (bulk)`, async () => { - const esError = { type: 'version_conflict_engine_exception' }; - await bulkCreateError(obj3, esError, expectErrorConflict(obj3)); - }); - - it(`returns error when document is missing`, async () => { - const esError = { type: 'document_missing_exception' }; - await bulkCreateError(obj3, esError, expectErrorNotFound(obj3)); - }); - - it(`returns error reason for other errors`, async () => { - const esError = { reason: 'some_other_error' }; - await bulkCreateError(obj3, esError, expectErrorResult(obj3, { message: esError.reason })); - }); - - it(`returns error string for other errors if no reason is defined`, async () => { - const esError = { foo: 'some_other_error' }; - const expectedError = expectErrorResult(obj3, { message: JSON.stringify(esError) }); - await bulkCreateError(obj3, esError, expectedError); + it(`returns bulk error`, async () => { + const expectedErrorResult = { type: obj3.type, id: obj3.id, error: 'Oh no, a bulk error!' }; + await bulkCreateError(obj3, true, expectedErrorResult); }); }); @@ -1530,16 +1367,22 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { + afterEach(() => { + mockGetBulkOperationError.mockReset(); + }); + const obj = { type: 'dashboard', id: 'three', }; - const bulkUpdateError = async (obj, esError, expectedError) => { + const bulkUpdateError = async (obj, isBulkError, expectedErrorResult) => { const objects = [obj1, obj, obj2]; const mockResponse = getMockBulkUpdateResponse(objects); - if (esError) { - mockResponse.items[1].update = { error: esError }; + if (isBulkError) { + // mock the bulk error for only the second object + mockGetBulkOperationError.mockReturnValueOnce(undefined); + mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error); } client.bulk.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) @@ -1547,14 +1390,14 @@ describe('SavedObjectsRepository', () => { const result = await savedObjectsRepository.bulkUpdate(objects); expect(client.bulk).toHaveBeenCalled(); - const objCall = esError ? expectObjArgs(obj) : []; + const objCall = isBulkError ? expectObjArgs(obj) : []; const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() ); expect(result).toEqual({ - saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], + saved_objects: [expectSuccess(obj1), expectedErrorResult, expectSuccess(obj2)], }); }; @@ -1592,19 +1435,19 @@ describe('SavedObjectsRepository', () => { it(`returns error when type is invalid`, async () => { const _obj = { ...obj, type: 'unknownType' }; - await bulkUpdateError(_obj, undefined, expectErrorNotFound(_obj)); + await bulkUpdateError(_obj, false, expectErrorNotFound(_obj)); }); it(`returns error when type is hidden`, async () => { const _obj = { ...obj, type: HIDDEN_TYPE }; - await bulkUpdateError(_obj, undefined, expectErrorNotFound(_obj)); + await bulkUpdateError(_obj, false, expectErrorNotFound(_obj)); }); it(`returns error when object namespace is '*'`, async () => { const _obj = { ...obj, namespace: '*' }; await bulkUpdateError( _obj, - undefined, + false, expectErrorResult(obj, createBadRequestError('"namespace" cannot be "*"')) ); }); @@ -1627,25 +1470,9 @@ describe('SavedObjectsRepository', () => { await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); - it(`returns error when there is a version conflict (bulk)`, async () => { - const esError = { type: 'version_conflict_engine_exception' }; - await bulkUpdateError(obj, esError, expectErrorConflict(obj)); - }); - - it(`returns error when document is missing (bulk)`, async () => { - const esError = { type: 'document_missing_exception' }; - await bulkUpdateError(obj, esError, expectErrorNotFound(obj)); - }); - - it(`returns error reason for other errors (bulk)`, async () => { - const esError = { reason: 'some_other_error' }; - await bulkUpdateError(obj, esError, expectErrorResult(obj, { message: esError.reason })); - }); - - it(`returns error string for other errors if no reason is defined (bulk)`, async () => { - const esError = { foo: 'some_other_error' }; - const expectedError = expectErrorResult(obj, { message: JSON.stringify(esError) }); - await bulkUpdateError(obj, esError, expectedError); + it(`returns bulk error`, async () => { + const expectedErrorResult = { type: obj.type, id: obj.id, error: 'Oh no, a bulk error!' }; + await bulkUpdateError(obj, true, expectedErrorResult); }); }); @@ -3898,352 +3725,6 @@ describe('SavedObjectsRepository', () => { }); }); - describe('#deleteFromNamespaces', () => { - const id = 'some-id'; - const type = MULTI_NAMESPACE_TYPE; - const namespace1 = 'default'; - const namespace2 = 'foo-namespace'; - const namespace3 = 'bar-namespace'; - - const mockGetResponse = (type, id, namespaces) => { - // mock a document that exists in two namespaces - const mockResponse = getMockGetResponse({ type, id }); - mockResponse._source.namespaces = namespaces; - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) - ); - }; - - const deleteFromNamespacesSuccess = async ( - type, - id, - namespaces, - currentNamespaces, - options - ) => { - mockGetResponse(type, id, currentNamespaces); - client.delete.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'deleted', - }) - ); - client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'updated', - }) - ); - - return await savedObjectsRepository.deleteFromNamespaces(type, id, namespaces, options); - }; - - describe('client calls', () => { - describe('delete action', () => { - const deleteFromNamespacesSuccessDelete = async (expectFn, options, _type = type) => { - const test = async (namespaces) => { - await deleteFromNamespacesSuccess(_type, id, namespaces, namespaces, options); - expectFn(); - client.delete.mockClear(); - client.get.mockClear(); - }; - await test([namespace1]); - await test([namespace1, namespace2]); - }; - - it(`should use ES get action then delete action if the object has no namespaces remaining`, async () => { - const expectFn = () => { - expect(client.delete).toHaveBeenCalledTimes(1); - expect(client.get).toHaveBeenCalledTimes(1); - }; - await deleteFromNamespacesSuccessDelete(expectFn); - }); - - it(`formats the ES requests`, async () => { - const expectFn = () => { - expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining({ - id: `${type}:${id}`, - }), - expect.anything() - ); - - const versionProperties = { - if_seq_no: mockVersionProps._seq_no, - if_primary_term: mockVersionProps._primary_term, - }; - expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining({ - id: `${type}:${id}`, - ...versionProperties, - }), - expect.anything() - ); - }; - await deleteFromNamespacesSuccessDelete(expectFn); - }); - - it(`defaults to a refresh setting of wait_for`, async () => { - await deleteFromNamespacesSuccessDelete(() => - expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining({ - refresh: 'wait_for', - }), - expect.anything() - ) - ); - }); - - it(`should use default index`, async () => { - const expectFn = () => - expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining({ index: '.kibana-test' }), - expect.anything() - ); - await deleteFromNamespacesSuccessDelete(expectFn); - }); - - it(`should use custom index`, async () => { - const expectFn = () => - expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining({ index: 'custom' }), - expect.anything() - ); - await deleteFromNamespacesSuccessDelete(expectFn, {}, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE); - }); - }); - - describe('update action', () => { - const deleteFromNamespacesSuccessUpdate = async (expectFn, options, _type = type) => { - const test = async (remaining) => { - const currentNamespaces = [namespace1].concat(remaining); - await deleteFromNamespacesSuccess(_type, id, [namespace1], currentNamespaces, options); - expectFn(); - client.get.mockClear(); - client.update.mockClear(); - }; - await test([namespace2]); - await test([namespace2, namespace3]); - }; - - it(`should use ES get action then update action if the object has one or more namespaces remaining`, async () => { - const expectFn = () => { - expect(client.update).toHaveBeenCalledTimes(1); - expect(client.get).toHaveBeenCalledTimes(1); - }; - await deleteFromNamespacesSuccessUpdate(expectFn); - }); - - it(`formats the ES requests`, async () => { - let ctr = 0; - const expectFn = () => { - expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ - id: `${type}:${id}`, - }), - expect.anything() - ); - const namespaces = ctr++ === 0 ? [namespace2] : [namespace2, namespace3]; - const versionProperties = { - if_seq_no: mockVersionProps._seq_no, - if_primary_term: mockVersionProps._primary_term, - }; - expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ - id: `${type}:${id}`, - ...versionProperties, - body: { doc: { ...mockTimestampFields, namespaces } }, - }), - expect.anything() - ); - }; - await deleteFromNamespacesSuccessUpdate(expectFn); - }); - - it(`defaults to a refresh setting of wait_for`, async () => { - const expectFn = () => - expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ - refresh: 'wait_for', - }), - expect.anything() - ); - await deleteFromNamespacesSuccessUpdate(expectFn); - }); - - it(`should use default index`, async () => { - const expectFn = () => - expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ index: '.kibana-test' }), - expect.anything() - ); - await deleteFromNamespacesSuccessUpdate(expectFn); - }); - - it(`should use custom index`, async () => { - const expectFn = () => - expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ index: 'custom' }), - expect.anything() - ); - await deleteFromNamespacesSuccessUpdate(expectFn, {}, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE); - }); - }); - }); - - describe('errors', () => { - const expectNotFoundError = async (type, id, namespaces, options) => { - await expect( - savedObjectsRepository.deleteFromNamespaces(type, id, namespaces, options) - ).rejects.toThrowError(createGenericNotFoundError(type, id)); - }; - const expectBadRequestError = async (type, id, namespaces, message) => { - await expect( - savedObjectsRepository.deleteFromNamespaces(type, id, namespaces) - ).rejects.toThrowError(createBadRequestError(message)); - }; - - it(`throws when type is invalid`, async () => { - await expectNotFoundError('unknownType', id, [namespace1, namespace2]); - expect(client.delete).not.toHaveBeenCalled(); - expect(client.update).not.toHaveBeenCalled(); - }); - - it(`throws when type is hidden`, async () => { - await expectNotFoundError(HIDDEN_TYPE, id, [namespace1, namespace2]); - expect(client.delete).not.toHaveBeenCalled(); - expect(client.update).not.toHaveBeenCalled(); - }); - - it(`throws when type is not shareable`, async () => { - const test = async (type) => { - const message = `${type} doesn't support multiple namespaces`; - await expectBadRequestError(type, id, [namespace1, namespace2], message); - expect(client.delete).not.toHaveBeenCalled(); - expect(client.update).not.toHaveBeenCalled(); - }; - await test('index-pattern'); - await test(MULTI_NAMESPACE_ISOLATED_TYPE); - await test(NAMESPACE_AGNOSTIC_TYPE); - }); - - it(`throws when namespaces is an empty array`, async () => { - const test = async (namespaces) => { - const message = 'namespaces must be a non-empty array of strings'; - await expectBadRequestError(type, id, namespaces, message); - expect(client.delete).not.toHaveBeenCalled(); - expect(client.update).not.toHaveBeenCalled(); - }; - await test([]); - }); - - it(`throws when ES is unable to find the document during get`, async () => { - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) - ); - await expectNotFoundError(type, id, [namespace1, namespace2]); - expect(client.get).toHaveBeenCalledTimes(1); - }); - - it(`throws when ES is unable to find the index during get`, async () => { - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) - ); - await expectNotFoundError(type, id, [namespace1, namespace2]); - expect(client.get).toHaveBeenCalledTimes(1); - }); - - it(`throws when the document exists, but not in this namespace`, async () => { - mockGetResponse(type, id, [namespace1]); - await expectNotFoundError(type, id, [namespace1], { namespace: 'some-other-namespace' }); - expect(client.get).toHaveBeenCalledTimes(1); - }); - - it(`throws when ES is unable to find the document during delete`, async () => { - mockGetResponse(type, id, [namespace1]); - client.delete.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'not_found' }) - ); - await expectNotFoundError(type, id, [namespace1]); - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.delete).toHaveBeenCalledTimes(1); - }); - - it(`throws when ES is unable to find the index during delete`, async () => { - mockGetResponse(type, id, [namespace1]); - client.delete.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - error: { type: 'index_not_found_exception' }, - }) - ); - await expectNotFoundError(type, id, [namespace1]); - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.delete).toHaveBeenCalledTimes(1); - }); - - it(`throws when ES returns an unexpected response`, async () => { - mockGetResponse(type, id, [namespace1]); - client.delete.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ - result: 'something unexpected', - }) - ); - await expect( - savedObjectsRepository.deleteFromNamespaces(type, id, [namespace1]) - ).rejects.toThrowError('Unexpected Elasticsearch DELETE response'); - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.delete).toHaveBeenCalledTimes(1); - }); - - it(`throws when ES is unable to find the document during update`, async () => { - mockGetResponse(type, id, [namespace1, namespace2]); - client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) - ); - await expectNotFoundError(type, id, [namespace1]); - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.update).toHaveBeenCalledTimes(1); - }); - }); - - describe('returns', () => { - it(`returns an empty namespaces array on success (delete)`, async () => { - const test = async (namespaces) => { - const result = await deleteFromNamespacesSuccess(type, id, namespaces, namespaces); - expect(result).toEqual({ namespaces: [] }); - client.delete.mockClear(); - }; - await test([namespace1]); - await test([namespace1, namespace2]); - }); - - it(`returns remaining namespaces on success (update)`, async () => { - const test = async (remaining) => { - const currentNamespaces = [namespace1].concat(remaining); - const result = await deleteFromNamespacesSuccess( - type, - id, - [namespace1], - currentNamespaces - ); - expect(result).toEqual({ namespaces: remaining }); - client.delete.mockClear(); - }; - await test([namespace2]); - await test([namespace2, namespace3]); - }); - - it(`succeeds when the document doesn't exist in all of the targeted namespaces`, async () => { - const namespaces = [namespace2]; - const currentNamespaces = [namespace1]; - const result = await deleteFromNamespacesSuccess(type, id, namespaces, currentNamespaces); - expect(result).toEqual({ namespaces: currentNamespaces }); - }); - }); - }); - describe('#update', () => { const id = 'logstash-*'; const type = 'index-pattern'; @@ -4722,4 +4203,65 @@ describe('SavedObjectsRepository', () => { ); }); }); + + describe('#collectMultiNamespaceReferences', () => { + afterEach(() => { + mockCollectMultiNamespaceReferences.mockReset(); + }); + + it('passes arguments to the collectMultiNamespaceReferences module and returns the result', async () => { + const objects = Symbol(); + const expectedResult = Symbol(); + mockCollectMultiNamespaceReferences.mockResolvedValue(expectedResult); + + await expect( + savedObjectsRepository.collectMultiNamespaceReferences(objects) + ).resolves.toEqual(expectedResult); + expect(mockCollectMultiNamespaceReferences).toHaveBeenCalledTimes(1); + expect(mockCollectMultiNamespaceReferences).toHaveBeenCalledWith( + expect.objectContaining({ objects }) + ); + }); + + it('returns an error from the collectMultiNamespaceReferences module', async () => { + const expectedResult = new Error('Oh no!'); + mockCollectMultiNamespaceReferences.mockRejectedValue(expectedResult); + + await expect(savedObjectsRepository.collectMultiNamespaceReferences([])).rejects.toEqual( + expectedResult + ); + }); + }); + + describe('#updateObjectsSpaces', () => { + afterEach(() => { + mockUpdateObjectsSpaces.mockReset(); + }); + + it('passes arguments to the updateObjectsSpaces module and returns the result', async () => { + const objects = Symbol(); + const spacesToAdd = Symbol(); + const spacesToRemove = Symbol(); + const options = Symbol(); + const expectedResult = Symbol(); + mockUpdateObjectsSpaces.mockResolvedValue(expectedResult); + + await expect( + savedObjectsRepository.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options) + ).resolves.toEqual(expectedResult); + expect(mockUpdateObjectsSpaces).toHaveBeenCalledTimes(1); + expect(mockUpdateObjectsSpaces).toHaveBeenCalledWith( + expect.objectContaining({ objects, spacesToAdd, spacesToRemove, options }) + ); + }); + + it('returns an error from the updateObjectsSpaces module', async () => { + const expectedResult = new Error('Oh no!'); + mockUpdateObjectsSpaces.mockRejectedValue(expectedResult); + + await expect(savedObjectsRepository.updateObjectsSpaces([], [], [])).rejects.toEqual( + expectedResult + ); + }); + }); }); 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 3eba77b465819..f044fe9279fbf 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 @@ -6,6 +6,36 @@ * Side Public License, v 1. */ +import type { collectMultiNamespaceReferences } from './collect_multi_namespace_references'; +import type * as InternalUtils from './internal_utils'; +import type { updateObjectsSpaces } from './update_objects_spaces'; + +export const mockCollectMultiNamespaceReferences = jest.fn() as jest.MockedFunction< + typeof collectMultiNamespaceReferences +>; + +jest.mock('./collect_multi_namespace_references', () => ({ + collectMultiNamespaceReferences: mockCollectMultiNamespaceReferences, +})); + +export const mockGetBulkOperationError = jest.fn() as jest.MockedFunction< + typeof InternalUtils['getBulkOperationError'] +>; + +jest.mock('./internal_utils', () => { + const actual = jest.requireActual('./internal_utils'); + return { + ...actual, + getBulkOperationError: mockGetBulkOperationError, + }; +}); + +export const mockUpdateObjectsSpaces = jest.fn() as jest.MockedFunction; + +jest.mock('./update_objects_spaces', () => ({ + updateObjectsSpaces: mockUpdateObjectsSpaces, +})); + export const pointInTimeFinderMock = jest.fn(); jest.doMock('./point_in_time_finder', () => ({ PointInTimeFinder: pointInTimeFinderMock, diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 2ef3be71407b0..c626a2b2acfb5 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -48,10 +48,6 @@ import { SavedObjectsBulkUpdateObject, SavedObjectsBulkUpdateOptions, SavedObjectsDeleteOptions, - SavedObjectsAddToNamespacesOptions, - SavedObjectsAddToNamespacesResponse, - SavedObjectsDeleteFromNamespacesOptions, - SavedObjectsDeleteFromNamespacesResponse, SavedObjectsRemoveReferencesToOptions, SavedObjectsRemoveReferencesToResponse, SavedObjectsResolveResponse, @@ -64,15 +60,31 @@ import { MutatingOperationRefreshSetting, } from '../../types'; import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; -import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; import { validateAndConvertAggregations } from './aggregations'; +import { + getBulkOperationError, + getExpectedVersionProperties, + getSavedObjectFromSource, + rawDocExistsInNamespace, +} from './internal_utils'; import { ALL_NAMESPACES_STRING, FIND_DEFAULT_PAGE, FIND_DEFAULT_PER_PAGE, SavedObjectsUtils, } from './utils'; +import { + collectMultiNamespaceReferences, + SavedObjectsCollectMultiNamespaceReferencesObject, + SavedObjectsCollectMultiNamespaceReferencesOptions, +} from './collect_multi_namespace_references'; +import { + updateObjectsSpaces, + SavedObjectsUpdateObjectsSpacesObject, + SavedObjectsUpdateObjectsSpacesOptions, +} from './update_objects_spaces'; // 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. @@ -95,7 +107,7 @@ export interface SavedObjectsRepositoryOptions { index: string; mappings: IndexMapping; client: ElasticsearchClient; - typeRegistry: SavedObjectTypeRegistry; + typeRegistry: ISavedObjectTypeRegistry; serializer: SavedObjectsSerializer; migrator: IKibanaMigrator; allowedTypes: string[]; @@ -134,7 +146,7 @@ export interface SavedObjectsDeleteByNamespaceOptions extends SavedObjectsBaseOp refresh?: boolean; } -const DEFAULT_REFRESH_SETTING = 'wait_for'; +export const DEFAULT_REFRESH_SETTING = 'wait_for'; /** * See {@link SavedObjectsRepository} @@ -160,7 +172,7 @@ export class SavedObjectsRepository { private _migrator: IKibanaMigrator; private _index: string; private _mappings: IndexMapping; - private _registry: SavedObjectTypeRegistry; + private _registry: ISavedObjectTypeRegistry; private _allowedTypes: string[]; private readonly client: RepositoryEsClient; private _serializer: SavedObjectsSerializer; @@ -176,7 +188,7 @@ export class SavedObjectsRepository { */ public static createRepository( migrator: IKibanaMigrator, - typeRegistry: SavedObjectTypeRegistry, + typeRegistry: ISavedObjectTypeRegistry, indexName: string, client: ElasticsearchClient, logger: Logger, @@ -511,16 +523,11 @@ export class SavedObjectsRepository { } const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value; - const { error, ...rawResponse } = Object.values( - bulkResponse?.body.items[esRequestIndex] ?? {} - )[0] as any; + const rawResponse = Object.values(bulkResponse?.body.items[esRequestIndex] ?? {})[0] as any; + const error = getBulkOperationError(rawMigratedDoc._source.type, requestedId, rawResponse); if (error) { - return { - id: requestedId, - type: rawMigratedDoc._source.type, - error: getBulkOperationError(error, rawMigratedDoc._source.type, requestedId), - }; + return { type: rawMigratedDoc._source.type, id: requestedId, error }; } // When method == 'index' the bulkResponse doesn't include the indexed @@ -989,7 +996,7 @@ export class SavedObjectsRepository { } // @ts-expect-error MultiGetHit._source is optional - return this.getSavedObjectFromSource(type, id, doc); + return getSavedObjectFromSource(this._registry, type, id, doc); }), }; } @@ -1033,7 +1040,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return this.getSavedObjectFromSource(type, id, body); + return getSavedObjectFromSource(this._registry, type, id, body); } /** @@ -1138,20 +1145,25 @@ export class SavedObjectsRepository { if (foundExactMatch && foundAliasMatch) { return { // @ts-expect-error MultiGetHit._source is optional - saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), + saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc), outcome: 'conflict', aliasTargetId: legacyUrlAlias.targetId, }; } else if (foundExactMatch) { return { // @ts-expect-error MultiGetHit._source is optional - saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), + saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc), outcome: 'exactMatch', }; } else if (foundAliasMatch) { return { - // @ts-expect-error MultiGetHit._source is optional - saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc), + saved_object: getSavedObjectFromSource( + this._registry, + type, + legacyUrlAlias.targetId, + // @ts-expect-error MultiGetHit._source is optional + aliasMatchDoc + ), outcome: 'aliasMatch', aliasTargetId: legacyUrlAlias.targetId, }; @@ -1263,169 +1275,52 @@ export class SavedObjectsRepository { } /** - * Adds one or more namespaces to a given multi-namespace saved object. This method and - * [`deleteFromNamespaces`]{@link SavedObjectsRepository.deleteFromNamespaces} are the only ways to change which Spaces a multi-namespace - * saved object is shared to. + * Gets all references and transitive references of the given objects. Ignores any object and/or reference that is not a multi-namespace + * type. + * + * @param objects The objects to get the references for. */ - async addToNamespaces( - type: string, - id: string, - namespaces: string[], - options: SavedObjectsAddToNamespacesOptions = {} - ): Promise { - if (!this._allowedTypes.includes(type)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - - if (!this._registry.isShareable(type)) { - throw SavedObjectsErrorHelpers.createBadRequestError( - `${type} doesn't support multiple namespaces` - ); - } - - if (!namespaces.length) { - throw SavedObjectsErrorHelpers.createBadRequestError( - 'namespaces must be a non-empty array of strings' - ); - } - - const { version, namespace, refresh = DEFAULT_REFRESH_SETTING } = options; - // we do not need to normalize the namespace to its ID format, since it will be converted to a namespace string before being used - - const rawId = this._serializer.generateRawId(undefined, type, id); - const preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); - const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult); - // there should never be a case where a multi-namespace object does not have any existing namespaces - // however, it is a possibility if someone manually modifies the document in Elasticsearch - const time = this._getCurrentTime(); - - const doc = { - updated_at: time, - namespaces: existingNamespaces ? unique(existingNamespaces.concat(namespaces)) : namespaces, - }; - - const { statusCode } = await this.client.update( - { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(version, preflightResult), - refresh, - body: { - doc, - }, - }, - { ignore: [404] } - ); - - if (statusCode === 404) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - - return { namespaces: doc.namespaces }; + async collectMultiNamespaceReferences( + objects: SavedObjectsCollectMultiNamespaceReferencesObject[], + options?: SavedObjectsCollectMultiNamespaceReferencesOptions + ) { + return collectMultiNamespaceReferences({ + registry: this._registry, + allowedTypes: this._allowedTypes, + client: this.client, + serializer: this._serializer, + getIndexForType: this.getIndexForType.bind(this), + createPointInTimeFinder: this.createPointInTimeFinder.bind(this), + objects, + options, + }); } /** - * Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted - * entirely. This method and [`addToNamespaces`]{@link SavedObjectsRepository.addToNamespaces} are the only ways to change which Spaces a - * multi-namespace saved object is shared to. + * Updates one or more objects to add and/or remove them from specified spaces. + * + * @param objects + * @param spacesToAdd + * @param spacesToRemove + * @param options */ - async deleteFromNamespaces( - type: string, - id: string, - namespaces: string[], - options: SavedObjectsDeleteFromNamespacesOptions = {} - ): Promise { - if (!this._allowedTypes.includes(type)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - - if (!this._registry.isShareable(type)) { - throw SavedObjectsErrorHelpers.createBadRequestError( - `${type} doesn't support multiple namespaces` - ); - } - - if (!namespaces.length) { - throw SavedObjectsErrorHelpers.createBadRequestError( - 'namespaces must be a non-empty array of strings' - ); - } - - const { namespace, refresh = DEFAULT_REFRESH_SETTING } = options; - // we do not need to normalize the namespace to its ID format, since it will be converted to a namespace string before being used - - const rawId = this._serializer.generateRawId(undefined, type, id); - const preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); - const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult); - // if there are somehow no existing namespaces, allow the operation to proceed and delete this saved object - const remainingNamespaces = existingNamespaces?.filter((x) => !namespaces.includes(x)); - - if (remainingNamespaces?.length) { - // if there is 1 or more namespace remaining, update the saved object - const time = this._getCurrentTime(); - - const doc = { - updated_at: time, - namespaces: remainingNamespaces, - }; - - const { statusCode } = await this.client.update( - { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - - body: { - doc, - }, - }, - { - ignore: [404], - } - ); - - if (statusCode === 404) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return { namespaces: doc.namespaces }; - } else { - // if there are no namespaces remaining, delete the saved object - const { body, statusCode } = await this.client.delete( - { - id: this._serializer.generateRawId(undefined, type, id), - refresh, - ...getExpectedVersionProperties(undefined, preflightResult), - index: this.getIndexForType(type), - }, - { - ignore: [404], - } - ); - - const deleted = body.result === 'deleted'; - if (deleted) { - return { namespaces: [] }; - } - - const deleteDocNotFound = body.result === 'not_found'; - // @ts-expect-error - const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; - if (deleteDocNotFound || deleteIndexNotFound) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - - throw new Error( - `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ - type, - id, - response: { body, statusCode }, - })}` - ); - } + async updateObjectsSpaces( + objects: SavedObjectsUpdateObjectsSpacesObject[], + spacesToAdd: string[], + spacesToRemove: string[], + options?: SavedObjectsUpdateObjectsSpacesOptions + ) { + return updateObjectsSpaces({ + registry: this._registry, + allowedTypes: this._allowedTypes, + client: this.client, + serializer: this._serializer, + getIndexForType: this.getIndexForType.bind(this), + objects, + spacesToAdd, + spacesToRemove, + options, + }); } /** @@ -1617,21 +1512,19 @@ export class SavedObjectsRepository { const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; const response = bulkUpdateResponse?.body.items[esRequestIndex] ?? {}; + const rawResponse = Object.values(response)[0] as any; + + const error = getBulkOperationError(type, id, rawResponse); + if (error) { + return { type, id, error }; + } + // When a bulk update operation is completed, any fields specified in `_sourceIncludes` will be found in the "get" value of the // returned object. We need to retrieve the `originId` if it exists so we can return it to the consumer. - const { error, _seq_no: seqNo, _primary_term: primaryTerm, get } = Object.values( - response - )[0] as any; + const { _seq_no: seqNo, _primary_term: primaryTerm, get } = rawResponse; // eslint-disable-next-line @typescript-eslint/naming-convention const { [type]: attributes, references, updated_at } = documentToSave; - if (error) { - return { - id, - type, - error: getBulkOperationError(error, type, id), - }; - } const { originId } = get._source; return { @@ -2055,10 +1948,10 @@ export class SavedObjectsRepository { * } * ``` */ - createPointInTimeFinder( + createPointInTimeFinder( findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies - ): ISavedObjectsPointInTimeFinder { + ): ISavedObjectsPointInTimeFinder { return new PointInTimeFinder(findOptions, { logger: this._logger, client: this, @@ -2108,28 +2001,8 @@ export class SavedObjectsRepository { return omit(savedObject, ['namespace']) as SavedObject; } - /** - * Check to ensure that a raw document exists in a namespace. If the document is not a multi-namespace type, then this returns `true` as - * we rely on the guarantees of the document ID format. If the document is a multi-namespace type, this checks to ensure that the - * document's `namespaces` value includes the string representation of the given namespace. - * - * WARNING: This should only be used for documents that were retrieved from Elasticsearch. Otherwise, the guarantees of the document ID - * format mentioned above do not apply. - */ - private rawDocExistsInNamespace(raw: SavedObjectsRawDoc, namespace?: string) { - const rawDocType = raw._source.type; - - // if the type is namespace isolated, or namespace agnostic, we can continue to rely on the guarantees - // of the document ID format and don't need to check this - if (!this._registry.isMultiNamespace(rawDocType)) { - return true; - } - - const namespaces = raw._source.namespaces; - const existsInNamespace = - namespaces?.includes(SavedObjectsUtils.namespaceIdToString(namespace)) || - namespaces?.includes('*'); - return existsInNamespace ?? false; + private rawDocExistsInNamespace(raw: SavedObjectsRawDoc, namespace: string | undefined) { + return rawDocExistsInNamespace(this._registry, raw, namespace); } /** @@ -2204,34 +2077,6 @@ export class SavedObjectsRepository { return body; } - 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, - coreMigrationVersion: doc._source.coreMigrationVersion, - }; - } - private async resolveExactMatch( type: string, id: string, @@ -2242,43 +2087,6 @@ export class SavedObjectsRepository { } } -function getBulkOperationError( - error: { type: string; reason?: string; index?: string }, - type: string, - id: string -) { - switch (error.type) { - case 'version_conflict_engine_exception': - return errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)); - case 'document_missing_exception': - return errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); - case 'index_not_found_exception': - return errorContent(SavedObjectsErrorHelpers.createIndexAliasNotFoundError(error.index!)); - default: - return { - message: error.reason || JSON.stringify(error), - }; - } -} - -/** - * Returns an object with the expected version properties. This facilitates Elasticsearch's Optimistic Concurrency Control. - * - * @param version Optional version specified by the consumer. - * @param document Optional existing document that was obtained in a preflight operation. - */ -function getExpectedVersionProperties(version?: string, document?: SavedObjectsRawDoc) { - if (version) { - return decodeRequestVersion(version); - } else if (document) { - return { - if_seq_no: document._seq_no, - if_primary_term: document._primary_term, - }; - } - return {}; -} - /** * Returns a string array of namespaces for a given saved object. If the saved object is undefined, the result is an array that contains the * current namespace. Value may be undefined if an existing saved object has no namespaces attribute; this should not happen in normal diff --git a/src/core/server/saved_objects/service/lib/update_objects_spaces.test.mock.ts b/src/core/server/saved_objects/service/lib/update_objects_spaces.test.mock.ts new file mode 100644 index 0000000000000..d7aa762e01aab --- /dev/null +++ b/src/core/server/saved_objects/service/lib/update_objects_spaces.test.mock.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type * as InternalUtils from './internal_utils'; + +export const mockGetBulkOperationError = jest.fn() as jest.MockedFunction< + typeof InternalUtils['getBulkOperationError'] +>; +export const mockGetExpectedVersionProperties = jest.fn() as jest.MockedFunction< + typeof InternalUtils['getExpectedVersionProperties'] +>; +export const mockRawDocExistsInNamespace = jest.fn() as jest.MockedFunction< + typeof InternalUtils['rawDocExistsInNamespace'] +>; + +jest.mock('./internal_utils', () => { + const actual = jest.requireActual('./internal_utils'); + return { + ...actual, + getBulkOperationError: mockGetBulkOperationError, + getExpectedVersionProperties: mockGetExpectedVersionProperties, + rawDocExistsInNamespace: mockRawDocExistsInNamespace, + }; +}); diff --git a/src/core/server/saved_objects/service/lib/update_objects_spaces.test.ts b/src/core/server/saved_objects/service/lib/update_objects_spaces.test.ts new file mode 100644 index 0000000000000..489432a4ab169 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/update_objects_spaces.test.ts @@ -0,0 +1,453 @@ +/* + * 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 { + mockGetBulkOperationError, + mockGetExpectedVersionProperties, + mockRawDocExistsInNamespace, +} from './update_objects_spaces.test.mock'; + +import type { DeeplyMockedKeys } from '@kbn/utility-types/target/jest'; +import type { ElasticsearchClient } from 'src/core/server/elasticsearch'; +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; + +import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; +import { SavedObjectsSerializer } from '../../serialization'; +import type { + SavedObjectsUpdateObjectsSpacesObject, + UpdateObjectsSpacesParams, +} from './update_objects_spaces'; +import { updateObjectsSpaces } from './update_objects_spaces'; + +type SetupParams = Partial< + Pick +>; + +const EXISTING_SPACE = 'existing-space'; +const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 }; +const EXPECTED_VERSION_PROPS = { if_seq_no: 1, if_primary_term: 1 }; +const BULK_ERROR = { + error: 'Oh no, a bulk error!', + type: 'error_type', + message: 'error_message', + statusCode: 400, +}; + +const SHAREABLE_OBJ_TYPE = 'type-a'; +const NON_SHAREABLE_OBJ_TYPE = 'type-b'; +const SHAREABLE_HIDDEN_OBJ_TYPE = 'type-c'; + +const mockCurrentTime = new Date('2021-05-01T10:20:30Z'); + +beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(mockCurrentTime); +}); + +beforeEach(() => { + mockGetExpectedVersionProperties.mockReturnValue(EXPECTED_VERSION_PROPS); + mockRawDocExistsInNamespace.mockReset(); + mockRawDocExistsInNamespace.mockReturnValue(true); // return true by default +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +describe('#updateObjectsSpaces', () => { + let client: DeeplyMockedKeys; + + /** Sets up the type registry, saved objects client, etc. and return the full parameters object to be passed to `updateObjectsSpaces` */ + function setup({ objects = [], spacesToAdd = [], spacesToRemove = [], options }: SetupParams) { + const registry = typeRegistryMock.create(); + registry.isShareable.mockImplementation( + (type) => [SHAREABLE_OBJ_TYPE, SHAREABLE_HIDDEN_OBJ_TYPE].includes(type) // NON_SHAREABLE_OBJ_TYPE is excluded + ); + client = elasticsearchClientMock.createElasticsearchClient(); + const serializer = new SavedObjectsSerializer(registry); + return { + registry, + allowedTypes: [SHAREABLE_OBJ_TYPE, NON_SHAREABLE_OBJ_TYPE], // SHAREABLE_HIDDEN_OBJ_TYPE is excluded + client, + serializer, + getIndexForType: (type: string) => `index-for-${type}`, + objects, + spacesToAdd, + spacesToRemove, + options, + }; + } + + /** Mocks the saved objects client so it returns the expected results */ + function mockMgetResults(...results: Array<{ found: boolean }>) { + client.mget.mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + docs: results.map((x) => + x.found + ? { + _id: 'doesnt-matter', + _index: 'doesnt-matter', + _source: { namespaces: [EXISTING_SPACE] }, + ...VERSION_PROPS, + found: true, + } + : { + _id: 'doesnt-matter', + _index: 'doesnt-matter', + found: false, + } + ), + }) + ); + } + + /** Asserts that mget is called for the given objects */ + function expectMgetArgs(...objects: SavedObjectsUpdateObjectsSpacesObject[]) { + const docs = objects.map(({ type, id }) => expect.objectContaining({ _id: `${type}:${id}` })); + expect(client.mget).toHaveBeenCalledWith({ body: { docs } }, expect.anything()); + } + + /** Mocks the saved objects client so it returns the expected results */ + function mockBulkResults(...results: Array<{ error: boolean }>) { + results.forEach(({ error }) => { + if (error) { + mockGetBulkOperationError.mockReturnValueOnce(BULK_ERROR); + } else { + mockGetBulkOperationError.mockReturnValueOnce(undefined); + } + }); + client.bulk.mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + items: results.map(() => ({})), // as long as the result does not contain an error field, it is treated as a success + errors: false, + took: 0, + }) + ); + } + + /** Asserts that mget is called for the given objects */ + function expectBulkArgs( + ...objectActions: Array<{ + object: { type: string; id: string; namespaces?: string[] }; + action: 'update' | 'delete'; + }> + ) { + const body = objectActions.flatMap( + ({ object: { type, id, namespaces = expect.any(Array) }, action }) => { + const operation = { + [action]: { + _id: `${type}:${id}`, + _index: `index-for-${type}`, + ...EXPECTED_VERSION_PROPS, + }, + }; + return action === 'update' + ? [operation, { doc: { namespaces, updated_at: mockCurrentTime.toISOString() } }] // 'update' uses an operation and document metadata + : [operation]; // 'delete' only uses an operation + } + ); + expect(client.bulk).toHaveBeenCalledWith(expect.objectContaining({ body })); + } + + beforeEach(() => { + mockGetBulkOperationError.mockReset(); // reset calls and return undefined by default + }); + + describe('errors', () => { + it('throws when spacesToAdd and spacesToRemove are empty', async () => { + const objects = [{ type: SHAREABLE_OBJ_TYPE, id: 'id-1' }]; + const params = setup({ objects }); + + await expect(() => updateObjectsSpaces(params)).rejects.toThrow( + 'spacesToAdd and/or spacesToRemove must be a non-empty array of strings: Bad Request' + ); + }); + + it('throws when spacesToAdd and spacesToRemove intersect', async () => { + const objects = [{ type: SHAREABLE_OBJ_TYPE, id: 'id-1' }]; + const spacesToAdd = ['foo-space', 'bar-space']; + const spacesToRemove = ['bar-space', 'baz-space']; + const params = setup({ objects, spacesToAdd, spacesToRemove }); + + await expect(() => updateObjectsSpaces(params)).rejects.toThrow( + 'spacesToAdd and spacesToRemove cannot contain any of the same strings: Bad Request' + ); + }); + + it('throws when mget cluster call fails', async () => { + const objects = [{ type: SHAREABLE_OBJ_TYPE, id: 'id-1' }]; + const spacesToAdd = ['foo-space']; + const params = setup({ objects, spacesToAdd }); + client.mget.mockReturnValueOnce( + elasticsearchClientMock.createErrorTransportRequestPromise(new Error('mget error')) + ); + + await expect(() => updateObjectsSpaces(params)).rejects.toThrow('mget error'); + }); + + it('throws when bulk cluster call fails', async () => { + const objects = [{ type: SHAREABLE_OBJ_TYPE, id: 'id-1' }]; + const spacesToAdd = ['foo-space']; + const params = setup({ objects, spacesToAdd }); + mockMgetResults({ found: true }); + client.bulk.mockReturnValueOnce( + elasticsearchClientMock.createErrorTransportRequestPromise(new Error('bulk error')) + ); + + await expect(() => updateObjectsSpaces(params)).rejects.toThrow('bulk error'); + }); + + it('returns mix of type errors, mget/bulk cluster errors, and successes', async () => { + const obj1 = { type: SHAREABLE_HIDDEN_OBJ_TYPE, id: 'id-1' }; // invalid type (Not Found) + const obj2 = { type: NON_SHAREABLE_OBJ_TYPE, id: 'id-2' }; // non-shareable type (Bad Request) + // obj3 below is mocking an example where a SOC wrapper attempted to retrieve it in a pre-flight request but it was not found. + // Since it has 'spaces: []', that indicates it should be skipped for cluster calls and just returned as a Not Found error. + // Realistically this would not be intermingled with other requested objects that do not have 'spaces' arrays, but it's fine for this + // specific test case. + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [] }; // does not exist (Not Found) + const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' }; // mget error (found but doesn't exist in the current space) + const obj5 = { type: SHAREABLE_OBJ_TYPE, id: 'id-5' }; // mget error (Not Found) + const obj6 = { type: SHAREABLE_OBJ_TYPE, id: 'id-6' }; // bulk error (mocked as BULK_ERROR) + const obj7 = { type: SHAREABLE_OBJ_TYPE, id: 'id-7' }; // success + + const objects = [obj1, obj2, obj3, obj4, obj5, obj6, obj7]; + const spacesToAdd = ['foo-space']; + const params = setup({ objects, spacesToAdd }); + mockMgetResults({ found: true }, { found: false }, { found: true }, { found: true }); // results for obj4, obj5, obj6, and obj7 + mockRawDocExistsInNamespace.mockReturnValueOnce(false); // for obj4 + mockRawDocExistsInNamespace.mockReturnValueOnce(true); // for obj6 + mockRawDocExistsInNamespace.mockReturnValueOnce(true); // for obj7 + mockBulkResults({ error: true }, { error: false }); // results for obj6 and obj7 + + const result = await updateObjectsSpaces(params); + expect(client.mget).toHaveBeenCalledTimes(1); + expectMgetArgs(obj4, obj5, obj6, obj7); + expect(mockRawDocExistsInNamespace).toHaveBeenCalledTimes(3); + expect(client.bulk).toHaveBeenCalledTimes(1); + expectBulkArgs({ action: 'update', object: obj6 }, { action: 'update', object: obj7 }); + expect(result.objects).toEqual([ + { ...obj1, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) }, + { ...obj2, spaces: [], error: expect.objectContaining({ error: 'Bad Request' }) }, + { ...obj3, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) }, + { ...obj4, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) }, + { ...obj5, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) }, + { ...obj6, spaces: [], error: BULK_ERROR }, + { ...obj7, spaces: [EXISTING_SPACE, 'foo-space'] }, + ]); + }); + }); + + // Note: these test cases do not include requested objects that will result in errors (those are covered above) + describe('cluster and module calls', () => { + it('mget call skips objects that have "spaces" defined', async () => { + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [EXISTING_SPACE] }; // will not be retrieved + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; // will be passed to mget + + const objects = [obj1, obj2]; + const spacesToAdd = ['foo-space']; + const params = setup({ objects, spacesToAdd }); + mockMgetResults({ found: true }); // result for obj2 + mockBulkResults({ error: false }, { error: false }); // results for obj1 and obj2 + + await updateObjectsSpaces(params); + expect(client.mget).toHaveBeenCalledTimes(1); + expectMgetArgs(obj2); + }); + + it('does not call mget if all objects have "spaces" defined', async () => { + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [EXISTING_SPACE] }; // will not be retrieved + + const objects = [obj1]; + const spacesToAdd = ['foo-space']; + const params = setup({ objects, spacesToAdd }); + mockBulkResults({ error: false }); // result for obj1 + + await updateObjectsSpaces(params); + expect(client.mget).not.toHaveBeenCalled(); + }); + + describe('bulk call skips objects that will not be changed', () => { + it('when adding spaces', async () => { + const space1 = 'space-to-add'; + const space2 = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space2] }; // will be updated + + const objects = [obj1, obj2]; + const spacesToAdd = [space1]; + const params = setup({ objects, spacesToAdd }); + // this test case does not call mget + mockBulkResults({ error: false }); // result for obj2 + + await updateObjectsSpaces(params); + expect(client.bulk).toHaveBeenCalledTimes(1); + expectBulkArgs({ + action: 'update', + object: { ...obj2, namespaces: [space2, space1] }, + }); + }); + + it('when removing spaces', async () => { + const space1 = 'space-to-remove'; + const space2 = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will not be changed + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space1, space2] }; // will be updated to remove space1 + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1] }; // will be deleted (since it would have no spaces left) + + const objects = [obj1, obj2, obj3]; + const spacesToRemove = [space1]; + const params = setup({ objects, spacesToRemove }); + // this test case does not call mget + mockBulkResults({ error: false }, { error: false }); // results for obj2 and obj3 + + await updateObjectsSpaces(params); + expect(client.bulk).toHaveBeenCalledTimes(1); + expectBulkArgs( + { action: 'update', object: { ...obj2, namespaces: [space2] } }, + { action: 'delete', object: obj3 } + ); + }); + + it('when adding and removing spaces', async () => { + const space1 = 'space-to-add'; + const space2 = 'space-to-remove'; + const space3 = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space3] }; // will be updated to add space1 + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1, space2] }; // will be updated to remove space2 + const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4', spaces: [space2, space3] }; // will be updated to add space1 and remove space2 + + const objects = [obj1, obj2, obj3, obj4]; + const spacesToAdd = [space1]; + const spacesToRemove = [space2]; + const params = setup({ objects, spacesToAdd, spacesToRemove }); + // this test case does not call mget + mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4 + + await updateObjectsSpaces(params); + expect(client.bulk).toHaveBeenCalledTimes(1); + expectBulkArgs( + { action: 'update', object: { ...obj2, namespaces: [space3, space1] } }, + { action: 'update', object: { ...obj3, namespaces: [space1] } }, + { action: 'update', object: { ...obj4, namespaces: [space3, space1] } } + ); + }); + }); + + describe('does not call bulk if all objects do not need to be changed', () => { + it('when adding spaces', async () => { + const space = 'space-to-add'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space] }; // will not be changed + + const objects = [obj1]; + const spacesToAdd = [space]; + const params = setup({ objects, spacesToAdd }); + // this test case does not call mget or bulk + + await updateObjectsSpaces(params); + expect(client.bulk).not.toHaveBeenCalled(); + }); + + it('when removing spaces', async () => { + const space1 = 'space-to-remove'; + const space2 = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will not be changed + + const objects = [obj1]; + const spacesToRemove = [space1]; + const params = setup({ objects, spacesToRemove }); + // this test case does not call mget or bulk + + await updateObjectsSpaces(params); + expect(client.bulk).not.toHaveBeenCalled(); + }); + + it('when adding and removing spaces', async () => { + const space1 = 'space-to-add'; + const space2 = 'space-to-remove'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed + + const objects = [obj1]; + const spacesToAdd = [space1]; + const spacesToRemove = [space2]; + const params = setup({ objects, spacesToAdd, spacesToRemove }); + // this test case does not call mget or bulk + + await updateObjectsSpaces(params); + expect(client.bulk).not.toHaveBeenCalled(); + }); + }); + }); + + describe('returns expected results', () => { + it('when adding spaces', async () => { + const space1 = 'space-to-add'; + const space2 = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space2] }; // will be updated + + const objects = [obj1, obj2]; + const spacesToAdd = [space1]; + const params = setup({ objects, spacesToAdd }); + // this test case does not call mget + mockBulkResults({ error: false }); // result for obj2 + + const result = await updateObjectsSpaces(params); + expect(result.objects).toEqual([ + { ...obj1, spaces: [space1] }, + { ...obj2, spaces: [space2, space1] }, + ]); + }); + + it('when removing spaces', async () => { + const space1 = 'space-to-remove'; + const space2 = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will not be changed + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space1, space2] }; // will be updated to remove space1 + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1] }; // will be deleted (since it would have no spaces left) + + const objects = [obj1, obj2, obj3]; + const spacesToRemove = [space1]; + const params = setup({ objects, spacesToRemove }); + // this test case does not call mget + mockBulkResults({ error: false }, { error: false }); // results for obj2 and obj3 + + const result = await updateObjectsSpaces(params); + expect(result.objects).toEqual([ + { ...obj1, spaces: [space2] }, + { ...obj2, spaces: [space2] }, + { ...obj3, spaces: [] }, + ]); + }); + + it('when adding and removing spaces', async () => { + const space1 = 'space-to-add'; + const space2 = 'space-to-remove'; + const space3 = 'other-space'; + const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed + const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space3] }; // will be updated to add space1 + const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1, space2] }; // will be updated to remove space2 + const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4', spaces: [space2, space3] }; // will be updated to add space1 and remove space2 + + const objects = [obj1, obj2, obj3, obj4]; + const spacesToAdd = [space1]; + const spacesToRemove = [space2]; + const params = setup({ objects, spacesToAdd, spacesToRemove }); + // this test case does not call mget + mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4 + + const result = await updateObjectsSpaces(params); + expect(result.objects).toEqual([ + { ...obj1, spaces: [space1] }, + { ...obj2, spaces: [space3, space1] }, + { ...obj3, spaces: [space1] }, + { ...obj4, spaces: [space3, space1] }, + ]); + }); + }); +}); 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 new file mode 100644 index 0000000000000..079549265385c --- /dev/null +++ b/src/core/server/saved_objects/service/lib/update_objects_spaces.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 type { BulkOperationContainer, MultiGetOperation } from '@elastic/elasticsearch/api/types'; +import intersection from 'lodash/intersection'; + +import type { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import type { SavedObjectsRawDocSource, SavedObjectsSerializer } from '../../serialization'; +import type { + MutatingOperationRefreshSetting, + SavedObjectError, + SavedObjectsBaseOptions, +} from '../../types'; +import type { DecoratedError } from './errors'; +import { SavedObjectsErrorHelpers } from './errors'; +import { + getBulkOperationError, + getExpectedVersionProperties, + rawDocExistsInNamespace, +} from './internal_utils'; +import { DEFAULT_REFRESH_SETTING } from './repository'; +import type { RepositoryEsClient } from './repository_es_client'; + +/** + * An object that should have its spaces updated. + * + * @public + */ +export interface SavedObjectsUpdateObjectsSpacesObject { + /** The type of the object to update */ + id: string; + /** The ID of the object to update */ + type: string; + /** + * The space(s) that the object to update currently exists in. This is only intended to be used by SOC wrappers. + * + * @internal + */ + spaces?: string[]; + /** + * The version of the object to update; this is used for optimistic concurrency control. This is only intended to be used by SOC wrappers. + * + * @internal + */ + version?: string; +} + +/** + * Options for the update operation. + * + * @public + */ +export interface SavedObjectsUpdateObjectsSpacesOptions extends SavedObjectsBaseOptions { + /** The Elasticsearch Refresh setting for this operation */ + refresh?: MutatingOperationRefreshSetting; +} + +/** + * The response when objects' spaces are updated. + * + * @public + */ +export interface SavedObjectsUpdateObjectsSpacesResponse { + objects: SavedObjectsUpdateObjectsSpacesResponseObject[]; +} + +/** + * Details about a specific object's update result. + * + * @public + */ +export interface SavedObjectsUpdateObjectsSpacesResponseObject { + /** The type of the referenced object */ + type: string; + /** The ID of the referenced object */ + id: string; + /** The space(s) that the referenced object exists in */ + spaces: string[]; + /** Included if there was an error updating this object's spaces */ + 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. + * + * @internal + */ +export interface UpdateObjectsSpacesParams { + registry: ISavedObjectTypeRegistry; + allowedTypes: string[]; + client: RepositoryEsClient; + serializer: SavedObjectsSerializer; + getIndexForType: (type: string) => string; + objects: SavedObjectsUpdateObjectsSpacesObject[]; + spacesToAdd: string[]; + spacesToRemove: string[]; + options?: SavedObjectsUpdateObjectsSpacesOptions; +} + +/** + * Gets all references and transitive references of the given objects. Ignores any object and/or reference that is not a multi-namespace + * type. + */ +export async function updateObjectsSpaces({ + registry, + allowedTypes, + client, + serializer, + getIndexForType, + objects, + spacesToAdd, + spacesToRemove, + options = {}, +}: UpdateObjectsSpacesParams): Promise { + if (!spacesToAdd.length && !spacesToRemove.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'spacesToAdd and/or spacesToRemove must be a non-empty array of strings' + ); + } + if (intersection(spacesToAdd, spacesToRemove).length > 0) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'spacesToAdd and spacesToRemove cannot contain any of the same strings' + ); + } + + const { namespace } = options; + + let bulkGetRequestIndexCounter = 0; + const expectedBulkGetResults: 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 }, + }; + } + if (!registry.isShareable(type)) { + const error = errorContent( + SavedObjectsErrorHelpers.createBadRequestError( + `${type} doesn't support multiple namespaces` + ) + ); + return { + tag: 'Left' as 'Left', + error: { id, type, spaces: [], error }, + }; + } + + return { + tag: 'Right' as 'Right', + value: { + type, + id, + spaces, + version, + ...(!spaces && { esRequestIndex: bulkGetRequestIndexCounter++ }), + }, + }; + }); + + const bulkGetDocs = expectedBulkGetResults.reduce((acc, x) => { + if (isRight(x) && x.value.esRequestIndex !== undefined) { + acc.push({ + _id: serializer.generateRawId(undefined, x.value.type, x.value.id), + _index: getIndexForType(x.value.type), + _source: ['type', 'namespaces'], + }); + } + return acc; + }, []); + const bulkGetResponse = bulkGetDocs.length + ? await client.mget( + { body: { docs: bulkGetDocs } }, + { ignore: [404] } + ) + : undefined; + + const time = new Date().toISOString(); + let bulkOperationRequestIndexCounter = 0; + const bulkOperationParams: BulkOperationContainer[] = []; + const expectedBulkOperationResults: Either[] = expectedBulkGetResults.map( + (expectedBulkGetResult) => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } + + 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. + const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); + return { + tag: 'Left' as 'Left', + error: { id, type, spaces: [], error }, + }; + } else { + versionProperties = getExpectedVersionProperties(version); + } + + const { newSpaces, isUpdateRequired } = getNewSpacesArray( + currentSpaces, + spacesToAdd, + spacesToRemove + ); + const expectedResult = { + type, + id, + newSpaces, + ...(isUpdateRequired && { esRequestIndex: bulkOperationRequestIndexCounter++ }), + }; + + 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' as 'Right', value: expectedResult }; + } + ); + + const { refresh = DEFAULT_REFRESH_SETTING } = options; + const bulkOperationResponse = bulkOperationParams.length + ? await client.bulk({ refresh, body: bulkOperationParams, require_alias: true }) + : undefined; + + return { + objects: expectedBulkOperationResults.map( + (expectedResult) => { + if (isLeft(expectedResult)) { + return expectedResult.error; + } + + const { type, id, newSpaces, esRequestIndex } = expectedResult.value; + if (esRequestIndex !== undefined) { + const response = bulkOperationResponse?.body.items[esRequestIndex] ?? {}; + const rawResponse = Object.values(response)[0] as any; + const error = getBulkOperationError(type, id, rawResponse); + if (error) { + return { id, type, spaces: [], error }; + } + } + + return { id, type, spaces: newSpaces }; + } + ), + }; +} + +/** Extracts the contents of a decorated error to return the attributes for bulk operations. */ +function errorContent(error: DecoratedError) { + return error.output.payload; +} + +/** Gets the remaining spaces for an object after adding new ones and removing old ones. */ +function getNewSpacesArray( + existingSpaces: string[], + spacesToAdd: string[], + spacesToRemove: string[] +) { + const addSet = new Set(spacesToAdd); + const removeSet = new Set(spacesToRemove); + const newSpaces = existingSpaces + .filter((x) => { + addSet.delete(x); + return !removeSet.delete(x); + }) + .concat(Array.from(addSet)); + + const isAnySpaceAdded = addSet.size > 0; + const isAnySpaceRemoved = removeSet.size < spacesToRemove.length; + const isUpdateRequired = isAnySpaceAdded || isAnySpaceRemoved; + + return { newSpaces, isUpdateRequired }; +} 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 544e92e32f1a1..e02387d41addf 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 @@ -26,9 +26,9 @@ const create = () => { openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }), resolve: jest.fn(), update: jest.fn(), - addToNamespaces: jest.fn(), - deleteFromNamespaces: jest.fn(), removeReferencesTo: jest.fn(), + collectMultiNamespaceReferences: jest.fn(), + updateObjectsSpaces: jest.fn(), } as unknown) as jest.Mocked; mock.createPointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ 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 29381c7e418b5..1a369475f2c6d 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 @@ -237,52 +237,39 @@ test(`#bulkUpdate`, async () => { expect(result).toBe(returnValue); }); -test(`#addToNamespaces`, async () => { +test(`#collectMultiNamespaceReferences`, async () => { const returnValue = Symbol(); const mockRepository = { - addToNamespaces: jest.fn().mockResolvedValue(returnValue), + collectMultiNamespaceReferences: jest.fn().mockResolvedValue(returnValue), }; const client = new SavedObjectsClient(mockRepository); - const type = Symbol(); - const id = Symbol(); - const namespaces = Symbol(); - const options = Symbol(); - const result = await client.addToNamespaces(type, id, namespaces, options); - - expect(mockRepository.addToNamespaces).toHaveBeenCalledWith(type, id, namespaces, options); - expect(result).toBe(returnValue); -}); - -test(`#deleteFromNamespaces`, async () => { - const returnValue = Symbol(); - const mockRepository = { - deleteFromNamespaces: jest.fn().mockResolvedValue(returnValue), - }; - const client = new SavedObjectsClient(mockRepository); - - const type = Symbol(); - const id = Symbol(); - const namespaces = Symbol(); + const objects = Symbol(); const options = Symbol(); - const result = await client.deleteFromNamespaces(type, id, namespaces, options); + const result = await client.collectMultiNamespaceReferences(objects, options); - expect(mockRepository.deleteFromNamespaces).toHaveBeenCalledWith(type, id, namespaces, options); + expect(mockRepository.collectMultiNamespaceReferences).toHaveBeenCalledWith(objects, options); expect(result).toBe(returnValue); }); -test(`#removeReferencesTo`, async () => { +test(`#updateObjectsSpaces`, async () => { const returnValue = Symbol(); const mockRepository = { - removeReferencesTo: jest.fn().mockResolvedValue(returnValue), + updateObjectsSpaces: jest.fn().mockResolvedValue(returnValue), }; const client = new SavedObjectsClient(mockRepository); - const type = Symbol(); - const id = Symbol(); + const objects = Symbol(); + const spacesToAdd = Symbol(); + const spacesToRemove = Symbol(); const options = Symbol(); - const result = await client.removeReferencesTo(type, id, options); - - expect(mockRepository.removeReferencesTo).toHaveBeenCalledWith(type, id, options); + const result = await client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options); + + expect(mockRepository.updateObjectsSpaces).toHaveBeenCalledWith( + objects, + spacesToAdd, + spacesToRemove, + options + ); expect(result).toBe(returnValue); }); 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 bf5cae0736cad..af682cfb81296 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -11,6 +11,11 @@ import type { ISavedObjectsPointInTimeFinder, SavedObjectsCreatePointInTimeFinderOptions, SavedObjectsCreatePointInTimeFinderDependencies, + SavedObjectsCollectMultiNamespaceReferencesObject, + SavedObjectsCollectMultiNamespaceReferencesOptions, + SavedObjectsCollectMultiNamespaceReferencesResponse, + SavedObjectsUpdateObjectsSpacesObject, + SavedObjectsUpdateObjectsSpacesOptions, } from './lib'; import { SavedObject, @@ -218,44 +223,6 @@ export interface SavedObjectsUpdateOptions extends SavedOb upsert?: Attributes; } -/** - * - * @public - */ -export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOptions { - /** An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. */ - version?: string; - /** The Elasticsearch Refresh setting for this operation */ - refresh?: MutatingOperationRefreshSetting; -} - -/** - * - * @public - */ -export interface SavedObjectsAddToNamespacesResponse { - /** The namespaces the object exists in after this operation is complete. */ - namespaces: string[]; -} - -/** - * - * @public - */ -export interface SavedObjectsDeleteFromNamespacesOptions extends SavedObjectsBaseOptions { - /** The Elasticsearch Refresh setting for this operation */ - refresh?: MutatingOperationRefreshSetting; -} - -/** - * - * @public - */ -export interface SavedObjectsDeleteFromNamespacesResponse { - /** The namespaces the object exists in after this operation is complete. An empty array indicates the object was deleted. */ - namespaces: string[]; -} - /** * * @public @@ -536,40 +503,6 @@ export class SavedObjectsClient { return await this._repository.update(type, id, attributes, options); } - /** - * Adds namespaces to a SavedObject - * - * @param type - * @param id - * @param namespaces - * @param options - */ - async addToNamespaces( - type: string, - id: string, - namespaces: string[], - options: SavedObjectsAddToNamespacesOptions = {} - ): Promise { - return await this._repository.addToNamespaces(type, id, namespaces, options); - } - - /** - * Removes namespaces from a SavedObject - * - * @param type - * @param id - * @param namespaces - * @param options - */ - async deleteFromNamespaces( - type: string, - id: string, - namespaces: string[], - options: SavedObjectsDeleteFromNamespacesOptions = {} - ): Promise { - return await this._repository.deleteFromNamespaces(type, id, namespaces, options); - } - /** * Bulk Updates multiple SavedObject at once * @@ -665,14 +598,49 @@ export class SavedObjectsClient { * } * ``` */ - createPointInTimeFinder( + createPointInTimeFinder( findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies - ): ISavedObjectsPointInTimeFinder { + ): ISavedObjectsPointInTimeFinder { return this._repository.createPointInTimeFinder(findOptions, { client: this, // Include dependencies last so that SO client wrappers have their settings applied. ...dependencies, }); } + + /** + * Gets all references and transitive references of the listed objects. Ignores any object that is not a multi-namespace type. + * + * @param objects + * @param options + */ + async collectMultiNamespaceReferences( + objects: SavedObjectsCollectMultiNamespaceReferencesObject[], + options?: SavedObjectsCollectMultiNamespaceReferencesOptions + ): Promise { + return await this._repository.collectMultiNamespaceReferences(objects, options); + } + + /** + * Updates one or more objects to add and/or remove them from specified spaces. + * + * @param objects + * @param spacesToAdd + * @param spacesToRemove + * @param options + */ + async updateObjectsSpaces( + objects: SavedObjectsUpdateObjectsSpacesObject[], + spacesToAdd: string[], + spacesToRemove: string[], + options?: SavedObjectsUpdateObjectsSpacesOptions + ) { + return await this._repository.updateObjectsSpaces( + objects, + spacesToAdd, + spacesToRemove, + options + ); + } } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index f4c70d718bc87..972e220baae3e 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1255,9 +1255,9 @@ export type ISavedObjectsExporter = PublicMethodsOf; export type ISavedObjectsImporter = PublicMethodsOf; // @public (undocumented) -export interface ISavedObjectsPointInTimeFinder { +export interface ISavedObjectsPointInTimeFinder { close: () => Promise; - find: () => AsyncGenerator; + find: () => AsyncGenerator>; } // @public @@ -2144,6 +2144,7 @@ export type SavedObjectAttributeSingle = string | number | boolean | null | unde // @public (undocumented) export interface SavedObjectExportBaseOptions { excludeExportDetails?: boolean; + includeNamespaces?: boolean; includeReferencesDeep?: boolean; namespace?: string; request: KibanaRequest; @@ -2175,15 +2176,18 @@ export interface SavedObjectReference { type: string; } -// @public (undocumented) -export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOptions { - refresh?: MutatingOperationRefreshSetting; - version?: string; -} - -// @public (undocumented) -export interface SavedObjectsAddToNamespacesResponse { - namespaces: string[]; +// @public +export interface SavedObjectReferenceWithContext { + id: string; + inboundReferences: Array<{ + type: string; + id: string; + name: string; + }>; + isMissing?: boolean; + spaces: string[]; + spacesWithMatchingAliases?: string[]; + type: string; } // Warning: (ae-forgotten-export) The symbol "SavedObjectDoc" needs to be exported by the entry point index.d.ts @@ -2277,16 +2281,15 @@ export interface SavedObjectsCheckConflictsResponse { export class SavedObjectsClient { // @internal constructor(repository: ISavedObjectsRepository); - addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise; bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; + collectMultiNamespaceReferences(objects: SavedObjectsCollectMultiNamespaceReferencesObject[], options?: SavedObjectsCollectMultiNamespaceReferencesOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; - createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; + createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; - deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; // (undocumented) static errors: typeof SavedObjectsErrorHelpers; // (undocumented) @@ -2297,6 +2300,7 @@ export class SavedObjectsClient { removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; + updateObjectsSpaces(objects: SavedObjectsUpdateObjectsSpacesObject[], spacesToAdd: string[], spacesToRemove: string[], options?: SavedObjectsUpdateObjectsSpacesOptions): Promise; } // @public @@ -2341,6 +2345,25 @@ export interface SavedObjectsClosePointInTimeResponse { succeeded: boolean; } +// @public +export interface SavedObjectsCollectMultiNamespaceReferencesObject { + // (undocumented) + id: string; + // (undocumented) + type: string; +} + +// @public +export interface SavedObjectsCollectMultiNamespaceReferencesOptions extends SavedObjectsBaseOptions { + purpose?: 'collectMultiNamespaceReferences' | 'updateObjectsSpaces'; +} + +// @public +export interface SavedObjectsCollectMultiNamespaceReferencesResponse { + // (undocumented) + objects: SavedObjectReferenceWithContext[]; +} + // @public export interface SavedObjectsComplexFieldMapping { // (undocumented) @@ -2401,16 +2424,6 @@ export interface SavedObjectsDeleteByNamespaceOptions extends SavedObjectsBaseOp refresh?: boolean; } -// @public (undocumented) -export interface SavedObjectsDeleteFromNamespacesOptions extends SavedObjectsBaseOptions { - refresh?: MutatingOperationRefreshSetting; -} - -// @public (undocumented) -export interface SavedObjectsDeleteFromNamespacesResponse { - namespaces: string[]; -} - // @public (undocumented) export interface SavedObjectsDeleteOptions extends SavedObjectsBaseOptions { force?: boolean; @@ -2884,21 +2897,20 @@ export interface SavedObjectsRemoveReferencesToResponse extends SavedObjectsBase // @public (undocumented) export class SavedObjectsRepository { - addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise; bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; + collectMultiNamespaceReferences(objects: SavedObjectsCollectMultiNamespaceReferencesObject[], options?: SavedObjectsCollectMultiNamespaceReferencesOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; - createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; + createPointInTimeFinder(findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies): ISavedObjectsPointInTimeFinder; // Warning: (ae-forgotten-export) The symbol "IKibanaMigrator" needs to be exported by the entry point index.d.ts // // @internal - static createRepository(migrator: IKibanaMigrator, typeRegistry: SavedObjectTypeRegistry, indexName: string, client: ElasticsearchClient, logger: Logger, includedHiddenTypes?: string[], injectedConstructor?: any): ISavedObjectsRepository; + static createRepository(migrator: IKibanaMigrator, typeRegistry: ISavedObjectTypeRegistry, indexName: string, client: ElasticsearchClient, logger: Logger, includedHiddenTypes?: string[], injectedConstructor?: any): ISavedObjectsRepository; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; - deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; // (undocumented) find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; @@ -2907,6 +2919,7 @@ export class SavedObjectsRepository { removeReferencesTo(type: string, id: string, options?: SavedObjectsRemoveReferencesToOptions): Promise; resolve(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; + updateObjectsSpaces(objects: SavedObjectsUpdateObjectsSpacesObject[], spacesToAdd: string[], spacesToRemove: string[], options?: SavedObjectsUpdateObjectsSpacesOptions): Promise; } // @public @@ -2938,7 +2951,7 @@ export class SavedObjectsSerializer { generateRawId(namespace: string | undefined, type: string, id: string): string; generateRawLegacyUrlAliasId(namespace: string, type: string, id: string): string; isRawSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): boolean; - rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): SavedObjectSanitizedDoc; + rawToSavedObject(doc: SavedObjectsRawDoc, options?: SavedObjectsRawDocParseOptions): SavedObjectSanitizedDoc; savedObjectToRaw(savedObj: SavedObjectSanitizedDoc): SavedObjectsRawDoc; } @@ -3004,6 +3017,35 @@ export interface SavedObjectsTypeMappingDefinition { properties: SavedObjectsMappingProperties; } +// @public +export interface SavedObjectsUpdateObjectsSpacesObject { + id: string; + // @internal + spaces?: string[]; + type: string; + // @internal + version?: string; +} + +// @public +export interface SavedObjectsUpdateObjectsSpacesOptions extends SavedObjectsBaseOptions { + refresh?: MutatingOperationRefreshSetting; +} + +// @public +export interface SavedObjectsUpdateObjectsSpacesResponse { + // (undocumented) + objects: SavedObjectsUpdateObjectsSpacesResponseObject[]; +} + +// @public +export interface SavedObjectsUpdateObjectsSpacesResponseObject { + error?: SavedObjectError; + id: string; + spaces: string[]; + type: string; +} + // @public (undocumented) export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { references?: SavedObjectReference[]; diff --git a/src/core/server/types.ts b/src/core/server/types.ts index be07a3cfb1fd3..77b5378f9477f 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -37,6 +37,10 @@ export type { SavedObjectsClientContract, SavedObjectsNamespaceType, } from './saved_objects/types'; +export type { + SavedObjectReferenceWithContext, + SavedObjectsCollectMultiNamespaceReferencesResponse, +} from './saved_objects/service'; export type { DomainDeprecationDetails, DeprecationsGetResponse } from './deprecations/types'; export * from './ui_settings/types'; export type { EnvironmentMode, PackageInfo } from '@kbn/config'; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index a71ce360a2190..dbb49825b2409 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -993,7 +993,7 @@ export class IndexPatternsServiceProvider implements Plugin_3, { expressions, usageCollection }: IndexPatternsServiceSetupDeps): void; // (undocumented) start(core: CoreStart, { fieldFormats, logger }: IndexPatternsServiceStartDeps): { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient_2) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: ElasticsearchClient_2) => Promise; }; } @@ -1263,7 +1263,7 @@ export class Plugin implements Plugin_2 Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index e460d9a43ef6b..ddee9c0528ba1 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -169,8 +169,8 @@ export interface ShareToSpaceFlyoutProps { behaviorContext?: 'within-space' | 'outside-space'; /** * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object. If - * this is not defined, a default handler will be used that calls `/api/spaces/_share_saved_object_add` and/or - * `/api/spaces/_share_saved_object_remove` and displays toast(s) indicating what occurred. + * this is not defined, a default handler will be used that calls `/api/spaces/_update_objects_spaces` and displays a toast indicating + * what occurred. */ changeSpacesHandler?: (spacesToAdd: string[], spacesToRemove: string[]) => Promise; /** diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index dcd34c604dc31..d009a66e9df55 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -574,6 +574,7 @@ export default ({ getService }: FtrProviderContext) => { id: 'legacy-url-alias:spacex:foo:1', type: 'legacy-url-alias', 'legacy-url-alias': { + sourceId: '1', targetId: newFooId, targetNamespace: 'spacex', targetType: 'foo', @@ -606,6 +607,7 @@ export default ({ getService }: FtrProviderContext) => { id: 'legacy-url-alias:spacex:bar:1', type: 'legacy-url-alias', 'legacy-url-alias': { + sourceId: '1', targetId: newBarId, targetNamespace: 'spacex', targetType: 'bar', 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 d18e7e427eeca..10a645295e2de 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 @@ -1819,6 +1819,82 @@ describe('#closePointInTime', () => { expect(mockBaseClient.closePointInTime).toHaveBeenCalledTimes(1); }); + + describe('#collectMultiNamespaceReferences', () => { + it('redirects request to underlying base client', async () => { + const objects = [{ type: 'foo', id: 'bar' }]; + const options = { namespace: 'some-ns' }; + await wrapper.collectMultiNamespaceReferences(objects, options); + + expect(mockBaseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); + expect(mockBaseClient.collectMultiNamespaceReferences).toHaveBeenCalledWith(objects, options); + }); + + it('returns response from underlying client', async () => { + const returnValue = { objects: [] }; + mockBaseClient.collectMultiNamespaceReferences.mockResolvedValue(returnValue); + + const objects = [{ type: 'foo', id: 'bar' }]; + const result = await wrapper.collectMultiNamespaceReferences(objects); + + expect(result).toBe(returnValue); + }); + + it('fails if base client fails', async () => { + const failureReason = new Error('Something bad happened...'); + mockBaseClient.collectMultiNamespaceReferences.mockRejectedValue(failureReason); + + const objects = [{ type: 'foo', id: 'bar' }]; + await expect(wrapper.collectMultiNamespaceReferences(objects)).rejects.toThrowError( + failureReason + ); + + expect(mockBaseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); + }); + }); + + describe('#updateObjectsSpaces', () => { + const objects = [{ type: 'foo', id: 'bar' }]; + const spacesToAdd = ['space-x']; + const spacesToRemove = ['space-y']; + const options = {}; + it('redirects request to underlying base client', async () => { + await wrapper.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options); + + expect(mockBaseClient.updateObjectsSpaces).toHaveBeenCalledTimes(1); + expect(mockBaseClient.updateObjectsSpaces).toHaveBeenCalledWith( + objects, + spacesToAdd, + spacesToRemove, + options + ); + }); + + it('returns response from underlying client', async () => { + const returnValue = { objects: [] }; + mockBaseClient.updateObjectsSpaces.mockResolvedValue(returnValue); + + const result = await wrapper.updateObjectsSpaces( + objects, + spacesToAdd, + spacesToRemove, + options + ); + + expect(result).toBe(returnValue); + }); + + it('fails if base client fails', async () => { + const failureReason = new Error('Something bad happened...'); + mockBaseClient.updateObjectsSpaces.mockRejectedValue(failureReason); + + await expect( + wrapper.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options) + ).rejects.toThrowError(failureReason); + + expect(mockBaseClient.updateObjectsSpaces).toHaveBeenCalledTimes(1); + }); + }); }); describe('#createPointInTimeFinder', () => { 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 9b699d6ce007c..a339f213bdce4 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 @@ -8,7 +8,6 @@ import type { ISavedObjectTypeRegistry, SavedObject, - SavedObjectsAddToNamespacesOptions, SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, @@ -18,15 +17,19 @@ import type { SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsClosePointInTimeOptions, + SavedObjectsCollectMultiNamespaceReferencesObject, + SavedObjectsCollectMultiNamespaceReferencesOptions, + SavedObjectsCollectMultiNamespaceReferencesResponse, SavedObjectsCreateOptions, SavedObjectsCreatePointInTimeFinderDependencies, SavedObjectsCreatePointInTimeFinderOptions, - SavedObjectsDeleteFromNamespacesOptions, SavedObjectsFindOptions, SavedObjectsFindResponse, SavedObjectsOpenPointInTimeOptions, SavedObjectsRemoveReferencesToOptions, SavedObjectsRemoveReferencesToResponse, + SavedObjectsUpdateObjectsSpacesObject, + SavedObjectsUpdateObjectsSpacesOptions, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, } from 'src/core/server'; @@ -228,24 +231,6 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon ); } - public async addToNamespaces( - type: string, - id: string, - namespaces: string[], - options?: SavedObjectsAddToNamespacesOptions - ) { - return await this.options.baseClient.addToNamespaces(type, id, namespaces, options); - } - - public async deleteFromNamespaces( - type: string, - id: string, - namespaces: string[], - options?: SavedObjectsDeleteFromNamespacesOptions - ) { - return await this.options.baseClient.deleteFromNamespaces(type, id, namespaces, options); - } - public async removeReferencesTo( type: string, id: string, @@ -265,17 +250,38 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.closePointInTime(id, options); } - public createPointInTimeFinder( + public createPointInTimeFinder( findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies ) { - return this.options.baseClient.createPointInTimeFinder(findOptions, { + return this.options.baseClient.createPointInTimeFinder(findOptions, { client: this, // Include dependencies last so that subsequent SO client wrappers have their settings applied. ...dependencies, }); } + public async collectMultiNamespaceReferences( + objects: SavedObjectsCollectMultiNamespaceReferencesObject[], + options?: SavedObjectsCollectMultiNamespaceReferencesOptions + ): Promise { + return await this.options.baseClient.collectMultiNamespaceReferences(objects, options); + } + + public async updateObjectsSpaces( + objects: SavedObjectsUpdateObjectsSpacesObject[], + spacesToAdd: string[], + spacesToRemove: string[], + options?: SavedObjectsUpdateObjectsSpacesOptions + ) { + return await this.options.baseClient.updateObjectsSpaces( + objects, + spacesToAdd, + spacesToRemove, + options + ); + } + /** * Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't * registered, response is returned as is. diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx index 912dfe99aa3ed..85d1301fee957 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx @@ -37,13 +37,17 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, const [showFlyout, setShowFlyout] = useState(false); - async function changeSpacesHandler(spacesToAdd: string[], spacesToRemove: string[]) { - if (spacesToAdd.length) { - const resp = await ml.savedObjects.assignJobToSpace(jobType, [jobId], spacesToAdd); - handleApplySpaces(resp); - } - if (spacesToRemove.length && !spacesToAdd.includes(ALL_SPACES_ID)) { - const resp = await ml.savedObjects.removeJobFromSpace(jobType, [jobId], spacesToRemove); + async function changeSpacesHandler(spacesToAdd: string[], spacesToMaybeRemove: string[]) { + // If the user is adding the job to all current and future spaces, don't remove it from any specified spaces + const spacesToRemove = spacesToAdd.includes(ALL_SPACES_ID) ? [] : spacesToMaybeRemove; + + if (spacesToAdd.length || spacesToRemove.length) { + const resp = await ml.savedObjects.updateJobsSpaces( + jobType, + [jobId], + spacesToAdd, + spacesToRemove + ); handleApplySpaces(resp); } onClose(); diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts index 38cbeb486df09..dd2e35f3f7759 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts @@ -26,18 +26,15 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({ method: 'GET', }); }, - assignJobToSpace(jobType: JobType, jobIds: string[], spaces: string[]) { - const body = JSON.stringify({ jobType, jobIds, spaces }); + updateJobsSpaces( + jobType: JobType, + jobIds: string[], + spacesToAdd: string[], + spacesToRemove: string[] + ) { + const body = JSON.stringify({ jobType, jobIds, spacesToAdd, spacesToRemove }); return httpService.http({ - path: `${basePath()}/saved_objects/assign_job_to_space`, - method: 'POST', - body, - }); - }, - removeJobFromSpace(jobType: JobType, jobIds: string[], spaces: string[]) { - const body = JSON.stringify({ jobType, jobIds, spaces }); - return httpService.http({ - path: `${basePath()}/saved_objects/remove_job_from_space`, + path: `${basePath()}/saved_objects/update_jobs_spaces`, method: 'POST', body, }); diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index 81db7ca15b258..803bd0ae4cb3a 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -776,10 +776,11 @@ export class DataRecognizer { this._request ); if (canCreateGlobalJobs === true) { - await this._jobSavedObjectService.assignJobsToSpaces( + await this._jobSavedObjectService.updateJobsSpaces( 'anomaly-detector', jobs.map((j) => j.id), - ['*'] + ['*'], // spacesToAdd + [] // spacesToRemove ); } } diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 7b54e48099d60..16cd3ea8df629 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -147,8 +147,7 @@ "SavedObjectsStatus", "SyncJobSavedObjects", "InitializeJobSavedObjects", - "AssignJobsToSpaces", - "RemoveJobsFromSpaces", + "UpdateJobsSpaces", "RemoveJobsFromCurrentSpace", "JobsSpaces", "CanDeleteJob", diff --git a/x-pack/plugins/ml/server/routes/saved_objects.ts b/x-pack/plugins/ml/server/routes/saved_objects.ts index c93730517cc11..e9fb748a4c7f8 100644 --- a/x-pack/plugins/ml/server/routes/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/saved_objects.ts @@ -126,15 +126,15 @@ export function savedObjectsRoutes( /** * @apiGroup JobSavedObjects * - * @api {post} /api/ml/saved_objects/assign_job_to_space Assign jobs to spaces - * @apiName AssignJobsToSpaces - * @apiDescription Add list of spaces to a list of jobs + * @api {post} /api/ml/saved_objects/update_jobs_spaces Update what spaces jobs are assigned to + * @apiName UpdateJobsSpaces + * @apiDescription Update a list of jobs to add and/or remove them from given spaces * * @apiSchema (body) jobsAndSpaces */ router.post( { - path: '/api/ml/saved_objects/assign_job_to_space', + path: '/api/ml/saved_objects/update_jobs_spaces', validate: { body: jobsAndSpaces, }, @@ -144,43 +144,14 @@ export function savedObjectsRoutes( }, routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService }) => { try { - const { jobType, jobIds, spaces } = request.body; + const { jobType, jobIds, spacesToAdd, spacesToRemove } = request.body; - const body = await jobSavedObjectService.assignJobsToSpaces(jobType, jobIds, spaces); - - return response.ok({ - body, - }); - } catch (e) { - return response.customError(wrapError(e)); - } - }) - ); - - /** - * @apiGroup JobSavedObjects - * - * @api {post} /api/ml/saved_objects/remove_job_from_space Remove jobs from spaces - * @apiName RemoveJobsFromSpaces - * @apiDescription Remove a list of spaces from a list of jobs - * - * @apiSchema (body) jobsAndSpaces - */ - router.post( - { - path: '/api/ml/saved_objects/remove_job_from_space', - validate: { - body: jobsAndSpaces, - }, - options: { - tags: ['access:ml:canCreateJob', 'access:ml:canCreateDataFrameAnalytics'], - }, - }, - routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService }) => { - try { - const { jobType, jobIds, spaces } = request.body; - - const body = await jobSavedObjectService.removeJobsFromSpaces(jobType, jobIds, spaces); + const body = await jobSavedObjectService.updateJobsSpaces( + jobType, + jobIds, + spacesToAdd, + spacesToRemove + ); return response.ok({ body, @@ -227,9 +198,12 @@ export function savedObjectsRoutes( }); } - const body = await jobSavedObjectService.removeJobsFromSpaces(jobType, jobIds, [ - currentSpaceId, - ]); + const body = await jobSavedObjectService.updateJobsSpaces( + jobType, + jobIds, + [], // spacesToAdd + [currentSpaceId] // spacesToRemove + ); return response.ok({ body, diff --git a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts index 85f56c1ffb412..64d0b291772f9 100644 --- a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts @@ -17,7 +17,8 @@ export const jobTypeSchema = schema.object({ jobType: jobTypeLiterals }); export const jobsAndSpaces = schema.object({ jobType: jobTypeLiterals, jobIds: schema.arrayOf(schema.string()), - spaces: schema.arrayOf(schema.string()), + spacesToAdd: schema.arrayOf(schema.string()), + spacesToRemove: schema.arrayOf(schema.string()), }); export const jobsAndCurrentSpace = schema.object({ diff --git a/x-pack/plugins/ml/server/saved_objects/service.ts b/x-pack/plugins/ml/server/saved_objects/service.ts index 7a39f2ed5ebfe..da7d11776ee53 100644 --- a/x-pack/plugins/ml/server/saved_objects/service.ts +++ b/x-pack/plugins/ml/server/saved_objects/service.ts @@ -301,51 +301,60 @@ export function jobSavedObjectServiceFactory( return filterJobObjectIdsForSpace('anomaly-detector', ids, 'datafeed_id', allowWildcards); } - async function assignJobsToSpaces(jobType: JobType, jobIds: string[], spaces: string[]) { + async function updateJobsSpaces( + jobType: JobType, + jobIds: string[], + spacesToAdd: string[], + spacesToRemove: string[] + ) { const results: Record = {}; const jobs = await _getJobObjects(jobType); - for (const id of jobIds) { - const job = jobs.find((j) => j.attributes.job_id === id); + const jobObjectIdMap = new Map(); + const objectsToUpdate: Array<{ type: string; id: string }> = []; + for (const jobId of jobIds) { + const job = jobs.find((j) => j.attributes.job_id === jobId); if (job === undefined) { - results[id] = { + results[jobId] = { success: false, - error: createError(id, 'job_id'), + error: createError(jobId, 'job_id'), }; } else { - try { - await savedObjectsClient.addToNamespaces(ML_SAVED_OBJECT_TYPE, job.id, spaces); - results[id] = { - success: true, - }; - } catch (error) { - results[id] = { - success: false, - error: getSavedObjectClientError(error), - }; - } + jobObjectIdMap.set(job.id, jobId); + objectsToUpdate.push({ type: ML_SAVED_OBJECT_TYPE, id: job.id }); } } - return results; - } - async function removeJobsFromSpaces(jobType: JobType, jobIds: string[], spaces: string[]) { - const results: Record = {}; - const jobs = await _getJobObjects(jobType); - for (const job of jobs) { - if (jobIds.includes(job.attributes.job_id)) { - try { - await savedObjectsClient.deleteFromNamespaces(ML_SAVED_OBJECT_TYPE, job.id, spaces); - results[job.attributes.job_id] = { - success: true, - }; - } catch (error) { - results[job.attributes.job_id] = { + try { + const updateResult = await savedObjectsClient.updateObjectsSpaces( + objectsToUpdate, + spacesToAdd, + spacesToRemove + ); + updateResult.objects.forEach(({ id: objectId, error }) => { + const jobId = jobObjectIdMap.get(objectId)!; + if (error) { + results[jobId] = { success: false, error: getSavedObjectClientError(error), }; + } else { + results[jobId] = { + success: true, + }; } - } + }); + } catch (error) { + // If the entire operation failed, return success: false for each job + const clientError = getSavedObjectClientError(error); + objectsToUpdate.forEach(({ id: objectId }) => { + const jobId = jobObjectIdMap.get(objectId)!; + results[jobId] = { + success: false, + error: clientError, + }; + }); } + return results; } @@ -372,8 +381,7 @@ export function jobSavedObjectServiceFactory( filterJobIdsForSpace, filterDatafeedsForSpace, filterDatafeedIdsForSpace, - assignJobsToSpaces, - removeJobsFromSpaces, + updateJobsSpaces, bulkCreateJobs, getAllJobObjectsForAllSpaces, canCreateGlobalJobs, diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 70d8149682370..611e7bd456da3 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -142,6 +142,8 @@ export enum SavedObjectAction { REMOVE_REFERENCES = 'saved_object_remove_references', OPEN_POINT_IN_TIME = 'saved_object_open_point_in_time', CLOSE_POINT_IN_TIME = 'saved_object_close_point_in_time', + COLLECT_MULTINAMESPACE_REFERENCES = 'saved_object_collect_multinamespace_references', // this is separate from 'saved_object_get' because the user is only accessing an object's metadata + UPDATE_OBJECTS_SPACES = 'saved_object_update_objects_spaces', // this is separate from 'saved_object_update' because the user is only updating an object's metadata } type VerbsTuple = [string, string, string]; @@ -170,6 +172,16 @@ const savedObjectAuditVerbs: Record = { 'removing references to', 'removed references to', ], + saved_object_collect_multinamespace_references: [ + 'collect references and spaces of', + 'collecting references and spaces of', + 'collected references and spaces of', + ], + saved_object_update_objects_spaces: [ + 'update spaces of', + 'updating spaces of', + 'updated spaces of', + ], }; const savedObjectAuditTypes: Record = { @@ -184,6 +196,8 @@ const savedObjectAuditTypes: Record = { saved_object_open_point_in_time: 'creation', saved_object_close_point_in_time: 'deletion', saved_object_remove_references: 'change', + saved_object_collect_multinamespace_references: 'access', + saved_object_update_objects_spaces: 'change', }; export interface SavedObjectEventParams { diff --git a/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts b/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts new file mode 100644 index 0000000000000..531b547a1f275 --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts @@ -0,0 +1,226 @@ +/* + * 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 type { SavedObjectsClientContract } from 'src/core/server'; + +import type { CheckSavedObjectsPrivileges } from '../authorization'; +import { Actions } from '../authorization'; +import type { CheckPrivilegesResponse } from '../authorization/types'; +import type { EnsureAuthorizedResult } from './ensure_authorized'; +import { ensureAuthorized, getEnsureAuthorizedActionResult } from './ensure_authorized'; + +describe('ensureAuthorized', () => { + function setupDependencies() { + const actions = new Actions('some-version'); + jest + .spyOn(actions.savedObject, 'get') + .mockImplementation((type: string, action: string) => `mock-saved_object:${type}/${action}`); + const errors = ({ + decorateForbiddenError: jest.fn().mockImplementation((err) => err), + decorateGeneralError: jest.fn().mockImplementation((err) => err), + } as unknown) as jest.Mocked; + const checkSavedObjectsPrivilegesAsCurrentUser: jest.MockedFunction = jest.fn(); + return { actions, errors, checkSavedObjectsPrivilegesAsCurrentUser }; + } + + // These arguments are used for all unit tests below + const types = ['a', 'b', 'c']; + const actions = ['foo', 'bar']; + const namespaces = ['x', 'y']; + + const mockAuthorizedResolvedPrivileges = { + hasAllRequested: true, + privileges: { + kibana: [ + { privilege: 'mock-saved_object:a/foo', authorized: true }, + { privilege: 'mock-saved_object:a/bar', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true }, + ], + }, + } as CheckPrivilegesResponse; + + test('calls checkSavedObjectsPrivilegesAsCurrentUser with expected privilege actions and namespaces', async () => { + const deps = setupDependencies(); + deps.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue( + mockAuthorizedResolvedPrivileges + ); + await ensureAuthorized(deps, types, actions, namespaces); + expect(deps.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + 'mock-saved_object:a/foo', + 'mock-saved_object:a/bar', + 'mock-saved_object:b/foo', + 'mock-saved_object:b/bar', + 'mock-saved_object:c/foo', + 'mock-saved_object:c/bar', + ], + namespaces + ); + }); + + test('throws an error when privilege check fails', async () => { + const deps = setupDependencies(); + deps.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error('Oh no!')); + expect(ensureAuthorized(deps, [], [], [])).rejects.toThrowError('Oh no!'); + }); + + describe('fully authorized', () => { + const expectedResult = { + status: 'fully_authorized', + typeActionMap: new Map([ + [ + 'a', + { + foo: { isGloballyAuthorized: true, authorizedSpaces: [] }, + bar: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }, + ], + ['b', { foo: { authorizedSpaces: ['x', 'y'] }, bar: { authorizedSpaces: ['x', 'y'] } }], + ['c', { foo: { authorizedSpaces: ['x', 'y'] }, bar: { authorizedSpaces: ['x', 'y'] } }], + ]), + }; + + test('with default options', async () => { + const deps = setupDependencies(); + deps.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue( + mockAuthorizedResolvedPrivileges + ); + const result = await ensureAuthorized(deps, types, actions, namespaces); + expect(result).toEqual(expectedResult); + }); + + test('with requireFullAuthorization=false', async () => { + const deps = setupDependencies(); + deps.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue( + mockAuthorizedResolvedPrivileges + ); + const options = { requireFullAuthorization: false }; + const result = await ensureAuthorized(deps, types, actions, namespaces, options); + expect(result).toEqual(expectedResult); + }); + }); + + describe('partially authorized', () => { + const resolvedPrivileges = { + hasAllRequested: false, + privileges: { + kibana: [ + // For type 'a', the user is authorized to use 'foo' action but not 'bar' action (all spaces) + // For type 'b', the user is authorized to use 'foo' action but not 'bar' action (both spaces) + // For type 'c', the user is authorized to use both actions in space 'x' but not space 'y' + { privilege: 'mock-saved_object:a/foo', authorized: true }, + { privilege: 'mock-saved_object:a/bar', authorized: false }, + { privilege: 'mock-saved_object:a/bar', authorized: true }, // fail-secure check + { resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: true }, + { resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: false }, + { resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: true }, + { privilege: 'mock-saved_object:c/foo', authorized: false }, // inverse fail-secure check + { resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: true }, + { resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: false }, + { privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check + { resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check + // The fail-secure checks are a contrived scenario, as we *shouldn't* get both an unauthorized and authorized result for a given resource... + // However, in case we do, we should fail-secure (authorized + unauthorized = unauthorized) + ], + }, + } as CheckPrivilegesResponse; + + test('with default options', async () => { + const deps = setupDependencies(); + deps.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue(resolvedPrivileges); + expect( + ensureAuthorized(deps, types, actions, namespaces) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Unable to (bar a),(bar b),(bar c),(foo c)"`); + }); + + test('with requireFullAuthorization=false', async () => { + const deps = setupDependencies(); + deps.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue(resolvedPrivileges); + const options = { requireFullAuthorization: false }; + const result = await ensureAuthorized(deps, types, actions, namespaces, options); + expect(result).toEqual({ + status: 'partially_authorized', + typeActionMap: new Map([ + ['a', { foo: { isGloballyAuthorized: true, authorizedSpaces: [] } }], + ['b', { foo: { authorizedSpaces: ['x', 'y'] } }], + ['c', { foo: { authorizedSpaces: ['x'] }, bar: { authorizedSpaces: ['x'] } }], + ]), + }); + }); + }); + + describe('unauthorized', () => { + const resolvedPrivileges = { + hasAllRequested: false, + privileges: { + kibana: [ + { privilege: 'mock-saved_object:a/foo', authorized: false }, + { privilege: 'mock-saved_object:a/bar', authorized: false }, + { privilege: 'mock-saved_object:a/bar', authorized: true }, // fail-secure check + { resource: 'x', privilege: 'mock-saved_object:b/foo', authorized: false }, + { resource: 'x', privilege: 'mock-saved_object:b/bar', authorized: false }, + { resource: 'x', privilege: 'mock-saved_object:c/foo', authorized: false }, + { resource: 'x', privilege: 'mock-saved_object:c/bar', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:a/foo', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:a/bar', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:b/foo', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:b/bar', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:c/foo', authorized: false }, + { resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: false }, + { privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check + { resource: 'y', privilege: 'mock-saved_object:c/bar', authorized: true }, // fail-secure check + // The fail-secure checks are a contrived scenario, as we *shouldn't* get both an unauthorized and authorized result for a given resource... + // However, in case we do, we should fail-secure (authorized + unauthorized = unauthorized) + ], + }, + } as CheckPrivilegesResponse; + + test('with default options', async () => { + const deps = setupDependencies(); + deps.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue(resolvedPrivileges); + expect( + ensureAuthorized(deps, types, actions, namespaces) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to (bar a),(bar b),(bar c),(foo a),(foo b),(foo c)"` + ); + }); + + test('with requireFullAuthorization=false', async () => { + const deps = setupDependencies(); + deps.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue(resolvedPrivileges); + const options = { requireFullAuthorization: false }; + const result = await ensureAuthorized(deps, types, actions, namespaces, options); + expect(result).toEqual({ status: 'unauthorized', typeActionMap: new Map() }); + }); + }); +}); + +describe('getEnsureAuthorizedActionResult', () => { + const typeActionMap: EnsureAuthorizedResult<'action'>['typeActionMap'] = new Map([ + ['type', { action: { authorizedSpaces: ['space-id'] } }], + ]); + + test('returns the appropriate result if it is in the typeActionMap', () => { + const result = getEnsureAuthorizedActionResult('type', 'action', typeActionMap); + expect(result).toEqual({ authorizedSpaces: ['space-id'] }); + }); + + test('returns an unauthorized result if it is not in the typeActionMap', () => { + const result = getEnsureAuthorizedActionResult('other-type', 'action', typeActionMap); + expect(result).toEqual({ authorizedSpaces: [] }); + }); +}); diff --git a/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts b/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts new file mode 100644 index 0000000000000..0ce7b5f78f13b --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts @@ -0,0 +1,165 @@ +/* + * 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 type { SavedObjectsClientContract } from 'src/core/server'; + +import type { Actions, CheckSavedObjectsPrivileges } from '../authorization'; +import type { CheckPrivilegesResponse } from '../authorization/types'; + +export interface EnsureAuthorizedDependencies { + actions: Actions; + errors: SavedObjectsClientContract['errors']; + checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; +} + +export interface EnsureAuthorizedOptions { + /** Whether or not to throw an error if the user is not fully authorized. Default is true. */ + requireFullAuthorization?: boolean; +} + +export interface EnsureAuthorizedResult { + status: 'fully_authorized' | 'partially_authorized' | 'unauthorized'; + typeActionMap: Map>; +} + +export interface EnsureAuthorizedActionResult { + authorizedSpaces: string[]; + isGloballyAuthorized?: boolean; +} + +/** + * Checks to ensure a user is authorized to access object types in given spaces. + * + * @param {EnsureAuthorizedDependencies} deps the dependencies needed to make the privilege checks. + * @param {string[]} types the type(s) to check privileges for. + * @param {T[]} actions the action(s) to check privileges for. + * @param {string[]} spaceIds the id(s) of spaces to check privileges for. + * @param {EnsureAuthorizedOptions} options the options to use. + */ +export async function ensureAuthorized( + deps: EnsureAuthorizedDependencies, + types: string[], + actions: T[], + spaceIds: string[], + options: EnsureAuthorizedOptions = {} +): Promise> { + const { requireFullAuthorization = true } = options; + const privilegeActionsMap = new Map( + types.flatMap((type) => + actions.map((action) => [deps.actions.savedObject.get(type, action), { type, action }]) + ) + ); + const privilegeActions = Array.from(privilegeActionsMap.keys()); + const { hasAllRequested, privileges } = await checkPrivileges(deps, privilegeActions, spaceIds); + + const missingPrivileges = getMissingPrivileges(privileges); + const typeActionMap = privileges.kibana.reduce< + Map> + >((acc, { resource, privilege }) => { + const missingPrivilegesAtResource = + (resource && missingPrivileges.get(resource)?.has(privilege)) || + (!resource && missingPrivileges.get(undefined)?.has(privilege)); + + if (missingPrivilegesAtResource) { + return acc; + } + const { type, action } = privilegeActionsMap.get(privilege)!; // always defined + const actionAuthorizations = acc.get(type) ?? ({} as Record); + const authorization: EnsureAuthorizedActionResult = actionAuthorizations[action] ?? { + authorizedSpaces: [], + }; + + if (resource === undefined) { + return acc.set(type, { + ...actionAuthorizations, + [action]: { ...authorization, isGloballyAuthorized: true }, + }); + } + + return acc.set(type, { + ...actionAuthorizations, + [action]: { + ...authorization, + authorizedSpaces: authorization.authorizedSpaces.concat(resource), + }, + }); + }, new Map()); + + if (hasAllRequested) { + return { typeActionMap, status: 'fully_authorized' }; + } + + if (!requireFullAuthorization) { + const isPartiallyAuthorized = typeActionMap.size > 0; + if (isPartiallyAuthorized) { + return { typeActionMap, status: 'partially_authorized' }; + } else { + return { typeActionMap, status: 'unauthorized' }; + } + } + + // Neither fully nor partially authorized. Bail with error. + const uniqueUnauthorizedPrivileges = [...missingPrivileges.entries()].reduce( + (acc, [, privilegeSet]) => new Set([...acc, ...privilegeSet]), + new Set() + ); + const targetTypesAndActions = [...uniqueUnauthorizedPrivileges] + .map((privilege) => { + const { type, action } = privilegeActionsMap.get(privilege)!; + return `(${action} ${type})`; + }) + .sort() + .join(','); + const msg = `Unable to ${targetTypesAndActions}`; + throw deps.errors.decorateForbiddenError(new Error(msg)); +} + +/** + * Helper function that, given an `EnsureAuthorizedResult`, checks to see what spaces the user is authorized to perform a given action for + * the given object type. + * + * @param {string} objectType the object type to check. + * @param {T} action the action to check. + * @param {EnsureAuthorizedResult['typeActionMap']} typeActionMap the typeActionMap from an EnsureAuthorizedResult. + */ +export function getEnsureAuthorizedActionResult( + objectType: string, + action: T, + typeActionMap: EnsureAuthorizedResult['typeActionMap'] +): EnsureAuthorizedActionResult { + const record = typeActionMap.get(objectType) ?? ({} as Record); + return record[action] ?? { authorizedSpaces: [] }; +} + +async function checkPrivileges( + deps: EnsureAuthorizedDependencies, + actions: string | string[], + namespaceOrNamespaces?: string | Array +) { + try { + return await deps.checkSavedObjectsPrivilegesAsCurrentUser(actions, namespaceOrNamespaces); + } catch (error) { + throw deps.errors.decorateGeneralError(error, error.body && error.body.reason); + } +} + +function getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { + return privileges.kibana.reduce>>( + (acc, { resource, privilege, authorized }) => { + if (!authorized) { + if (resource) { + acc.set(resource, (acc.get(resource) || new Set()).add(privilege)); + } + // Fail-secure: if a user is not authorized for a specific resource, they are not authorized for the global resource too (global resource is undefined) + // The inverse is not true; if a user is not authorized for the global resource, they may still be authorized for a specific resource + acc.set(undefined, (acc.get(undefined) || new Set()).add(privilege)); + } + return acc; + }, + new Map() + ); +} diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.mocks.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.mocks.ts new file mode 100644 index 0000000000000..9e772f5394cc2 --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.mocks.ts @@ -0,0 +1,17 @@ +/* + * 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 type { ensureAuthorized } from './ensure_authorized'; + +export const mockEnsureAuthorized = jest.fn() as jest.MockedFunction; + +jest.mock('./ensure_authorized', () => { + return { + ...jest.requireActual('./ensure_authorized'), + ensureAuthorized: mockEnsureAuthorized, + }; +}); 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 2658f4edec5ac..e5a2340aba3f0 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 @@ -5,7 +5,15 @@ * 2.0. */ -import type { EcsEventOutcome, SavedObjectsClientContract } from 'src/core/server'; +import { mockEnsureAuthorized } from './secure_saved_objects_client_wrapper.test.mocks'; + +import type { + EcsEventOutcome, + SavedObject, + SavedObjectReferenceWithContext, + SavedObjectsClientContract, + SavedObjectsUpdateObjectsSpacesResponseObject, +} from 'src/core/server'; import { httpServerMock, savedObjectsClientMock } from 'src/core/server/mocks'; import type { AuditEvent } from '../audit'; @@ -20,6 +28,7 @@ jest.mock('src/core/server/saved_objects/service/lib/utils', () => { ); return { SavedObjectsUtils: { + ...SavedObjectsUtils, createEmptyFindResponse: SavedObjectsUtils.createEmptyFindResponse, generateId: () => 'mock-saved-object-id', }, @@ -179,8 +188,6 @@ const expectObjectNamespaceFiltering = async ( 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); const result = await fn.bind(client)(...Object.values(args)); // we will never redact the "All Spaces" ID @@ -210,7 +217,7 @@ const expectAuditEvent = ( }), kibana: savedObject ? expect.objectContaining({ - saved_object: savedObject, + saved_object: { type: savedObject.type, id: savedObject.id }, }) : expect.anything(), }) @@ -313,146 +320,12 @@ beforeEach(() => { clientOpts = createSecureSavedObjectsClientWrapperOptions(); client = new SecureSavedObjectsClientWrapper(clientOpts); - // succeed privilege checks by default + // succeed legacyEnsureAuthorized privilege checks by default clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( getMockCheckPrivilegesSuccess ); -}); - -describe('#addToNamespaces', () => { - const type = 'foo'; - const id = `${type}-id`; - const newNs1 = 'foo-namespace'; - const newNs2 = 'bar-namespace'; - const namespaces = [newNs1, newNs2]; - const currentNs = 'default'; - const privilege = `mock-saved_object:${type}/share_to_space`; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - await expectGeneralError(client.addToNamespaces, { type, id, namespaces }); - }); - - test(`throws decorated ForbiddenError when unauthorized to create in new space`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure - ); - - await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrowError( - clientOpts.forbiddenError - ); - - expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); - expect( - clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure - ).toHaveBeenCalledWith( - USERNAME, - 'addToNamespacesCreate', - [type], - namespaces.sort(), - [{ privilege, spaceId: newNs1 }], - { id, type, namespaces, options: {} } - ); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized to update in current space`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( - getMockCheckPrivilegesSuccess // create - ); - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure // update - ); - - await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrowError( - clientOpts.forbiddenError - ); - expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); - expect( - clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure - ).toHaveBeenLastCalledWith( - USERNAME, - 'addToNamespacesUpdate', - [type], - [currentNs], - [{ privilege, spaceId: currentNs }], - { id, type, namespaces, options: {} } - ); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); - }); - - test(`returns result of baseClient.addToNamespaces when authorized`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.addToNamespaces.mockReturnValue(apiCallReturnValue as any); - - const result = await client.addToNamespaces(type, id, namespaces); - expect(result).toBe(apiCallReturnValue); - - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(2); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( - 1, - USERNAME, - 'addToNamespacesCreate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesCreate' - [type], - namespaces.sort(), - { type, id, namespaces, options: {} } - ); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( - 2, - USERNAME, - 'addToNamespacesUpdate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesUpdate' - [type], - [currentNs], - { type, id, namespaces, options: {} } - ); - }); - - test(`checks privileges for user, actions, and namespaces`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( - getMockCheckPrivilegesSuccess // create - ); - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure // update - ); - - await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrow(); // test is simpler with error case - - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( - 1, - [privilege], - namespaces - ); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( - 2, - [privilege], - undefined // default namespace - ); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - // this operation is unique because it requires two privilege checks before it executes - await expectObjectNamespaceFiltering(client.addToNamespaces, { type, id, namespaces }, 2); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.addToNamespaces.mockReturnValue(apiCallReturnValue as any); - await client.addToNamespaces(type, id, namespaces); - - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_add_to_spaces', 'unknown', { type, id }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.addToNamespaces(type, id, namespaces)).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_add_to_spaces', 'failure', { type, id }); - }); + mockEnsureAuthorized.mockReset(); }); describe('#bulkCreate', () => { @@ -1163,92 +1036,6 @@ describe('#resolve', () => { }); }); -describe('#deleteFromNamespaces', () => { - const type = 'foo'; - const id = `${type}-id`; - const namespace1 = 'default'; - const namespace2 = 'another-namespace'; - const namespaces = [namespace1, namespace2]; - const privilege = `mock-saved_object:${type}/share_to_space`; - - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - await expectGeneralError(client.deleteFromNamespaces, { type, id, namespaces }); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure - ); - - await expect(client.deleteFromNamespaces(type, id, namespaces)).rejects.toThrowError( - clientOpts.forbiddenError - ); - - expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - USERNAME, - 'deleteFromNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'deleteFromNamespaces' - [type], - namespaces.sort(), - [{ privilege, spaceId: namespace1 }], - { type, id, namespaces, options: {} } - ); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.deleteFromNamespaces when authorized`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(apiCallReturnValue as any); - - const result = await client.deleteFromNamespaces(type, id, namespaces); - expect(result).toBe(apiCallReturnValue); - - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - USERNAME, - 'deleteFromNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'deleteFromNamespaces' - [type], - namespaces.sort(), - { type, id, namespaces, options: {} } - ); - }); - - test(`checks privileges for user, actions, and namespace`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure - ); - - await expect(client.deleteFromNamespaces(type, id, namespaces)).rejects.toThrow(); // test is simpler with error case - - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [privilege], - namespaces - ); - }); - - test(`filters namespaces that the user doesn't have access to`, async () => { - await expectObjectNamespaceFiltering(client.deleteFromNamespaces, { type, id, namespaces }); - }); - - test(`adds audit event when successful`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(apiCallReturnValue as any); - await client.deleteFromNamespaces(type, id, namespaces); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_delete_from_spaces', 'unknown', { type, id }); - }); - - test(`adds audit event when not successful`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); - await expect(() => client.deleteFromNamespaces(type, id, namespaces)).rejects.toThrow(); - expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_delete_from_spaces', 'failure', { type, id }); - }); -}); - describe('#update', () => { const type = 'foo'; const id = `${type}-id`; @@ -1351,6 +1138,583 @@ describe('#removeReferencesTo', () => { }); }); +/** + * Naming conventions used in this group of tests: + * * 'reqObj' is an object that the consumer requests (SavedObjectsCollectMultiNamespaceReferencesObject) + * * 'obj' is the object result that was fetched from Elasticsearch (SavedObjectReferenceWithContext) + */ +describe('#collectMultiNamespaceReferences', () => { + const AUDIT_ACTION = 'saved_object_collect_multinamespace_references'; + const spaceX = 'space-x'; + const spaceY = 'space-y'; + const spaceZ = 'space-z'; + + /** Returns a valid inboundReferences field for mock baseClient results. */ + function getInboundRefsFrom( + ...objects: Array<{ type: string; id: string }> + ): Pick { + return { + inboundReferences: objects.map(({ type, id }) => { + return { type, id, name: `ref-${type}:${id}` }; + }), + }; + } + + beforeEach(() => { + // by default, the result is a success, each object exists in the current space and another space + clientOpts.baseClient.collectMultiNamespaceReferences.mockImplementation((objects) => + Promise.resolve({ + objects: objects.map(({ type, id }) => ({ + type, + id, + spaces: [spaceX, spaceY, spaceZ], + inboundReferences: [], + })), + }) + ); + }); + + describe('errors', () => { + const reqObj1 = { type: 'a', id: '1' }; + const reqObj2 = { type: 'b', id: '2' }; + const reqObj3 = { type: 'c', id: '3' }; + + test(`throws an error if the base client operation fails`, async () => { + clientOpts.baseClient.collectMultiNamespaceReferences.mockRejectedValue(new Error('Oh no!')); + await expect(() => + client.collectMultiNamespaceReferences([reqObj1], { namespace: spaceX }) + ).rejects.toThrowError('Oh no!'); + expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.log).not.toHaveBeenCalled(); + }); + + describe(`throws decorated ForbiddenError and adds audit events when unauthorized`, () => { + test(`with purpose 'collectMultiNamespaceReferences'`, async () => { + // Use the default mocked results for the base client call. + // This fails because the user is not authorized to bulk_get type 'c' in the current space. + mockEnsureAuthorized.mockResolvedValue({ + status: 'partially_authorized', + typeActionMap: new Map() + .set('a', { bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] } }) + .set('b', { bulk_get: { authorizedSpaces: [spaceX, spaceY] } }) + .set('c', { bulk_get: { authorizedSpaces: [spaceY] } }), + }); + const options = { namespace: spaceX }; // spaceX is the current space + await expect(() => + client.collectMultiNamespaceReferences([reqObj1, reqObj2, reqObj3], options) + ).rejects.toThrowError(clientOpts.forbiddenError); + expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(3); + expectAuditEvent(AUDIT_ACTION, 'failure', reqObj1); + expectAuditEvent(AUDIT_ACTION, 'failure', reqObj2); + expectAuditEvent(AUDIT_ACTION, 'failure', reqObj3); + }); + + test(`with purpose 'updateObjectsSpaces'`, async () => { + // Use the default mocked results for the base client call. + // This fails because the user is not authorized to share_to_space type 'c' in the current space. + mockEnsureAuthorized.mockResolvedValue({ + status: 'partially_authorized', + typeActionMap: new Map() + .set('a', { + bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, + share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('b', { + bulk_get: { authorizedSpaces: [spaceX, spaceY] }, + share_to_space: { authorizedSpaces: [spaceX, spaceY] }, + }) + .set('c', { + bulk_get: { authorizedSpaces: [spaceX, spaceY] }, + share_to_space: { authorizedSpaces: [spaceY] }, + }), + }); + const options = { namespace: spaceX, purpose: 'updateObjectsSpaces' as const }; // spaceX is the current space + await expect(() => + client.collectMultiNamespaceReferences([reqObj1, reqObj2, reqObj3], options) + ).rejects.toThrowError(clientOpts.forbiddenError); + expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(3); + expectAuditEvent(AUDIT_ACTION, 'failure', reqObj1); + expectAuditEvent(AUDIT_ACTION, 'failure', reqObj2); + expectAuditEvent(AUDIT_ACTION, 'failure', reqObj3); + }); + }); + + test(`throws an error if the base client result includes a requested object without a valid inbound reference`, async () => { + // We *shouldn't* ever get an inbound reference that is not also present in the base client response objects array. + const spaces = [spaceX]; + + const obj1 = { ...reqObj1, spaces, inboundReferences: [] }; + const obj2 = { + type: 'a', + id: '2', + spaces, + ...getInboundRefsFrom({ type: 'some-type', id: 'some-id' }), + }; + clientOpts.baseClient.collectMultiNamespaceReferences.mockResolvedValueOnce({ + objects: [obj1, obj2], + }); + mockEnsureAuthorized.mockResolvedValue({ + status: 'partially_authorized', + typeActionMap: new Map().set('a', { + bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }), + }); + // When the loop gets to obj2, it will determine that the user is authorized for the object but *not* for the graph. However, it will + // also determine that there is *no* valid inbound reference tying this object back to what was requested. In this case, throw an + // error. + + const options = { namespace: spaceX }; // spaceX is the current space + await expect(() => + client.collectMultiNamespaceReferences([reqObj1], options) + ).rejects.toThrowError('Unexpected inbound reference to "some-type:some-id"'); + }); + }); + + describe(`checks privileges`, () => { + // Other test cases below contain more complex assertions for privilege checks, but these focus on the current space (default vs non-default) + const reqObj1 = { type: 'a', id: '1' }; + const obj1 = { ...reqObj1, spaces: ['*'], inboundReferences: [] }; + + beforeEach(() => { + clientOpts.baseClient.collectMultiNamespaceReferences.mockResolvedValueOnce({ + objects: [obj1], + }); + mockEnsureAuthorized.mockResolvedValue({ + status: 'fully_authorized', + typeActionMap: new Map().set('a', { + bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, // success case for the simplest test + }), + }); + }); + + test(`in the default space`, async () => { + await client.collectMultiNamespaceReferences([reqObj1]); + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledWith( + expect.any(Object), // dependencies + ['a'], // unique types of the fetched objects + ['bulk_get'], // actions + ['default'], // unique spaces that the fetched objects exist in, along with the current space + { requireFullAuthorization: false } + ); + }); + + test(`in a non-default space`, async () => { + await client.collectMultiNamespaceReferences([reqObj1], { namespace: spaceX }); + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledWith( + expect.any(Object), // dependencies + ['a'], // unique types of the fetched objects + ['bulk_get'], // actions + [spaceX], // unique spaces that the fetched objects exist in, along with the current space + { requireFullAuthorization: false } + ); + }); + }); + + describe(`checks privileges, filters/redacts objects correctly, and records audit events`, () => { + const reqObj1 = { type: 'a', id: '1' }; + const reqObj2 = { type: 'b', id: '2' }; + const spaces = [spaceX, spaceY, spaceZ]; + + // Actual object graph: + // ─► obj1 (a:1) ─┬─► obj3 (c:3) ───► obj5 (c:5) ─► obj8 (c:8) ─┐ + // │ ▲ │ + // │ │ │ + // └─► obj4 (d:4) ─┬─► obj6 (c:6) ◄──────────────┘ + // ─► obj2 (b:2) └─► obj7 (c:7) + // + // Object graph that the consumer sees after authorization: + // ─► obj1 (a:1) ─┬─► obj3 (c:3) ───► obj5 (c:5) ─► obj8 (c:8) ─► obj6 (c:6) ─┐ + // │ ▲ │ + // │ └───────────────────────────────────┘ + // └─► obj4 (d:4) + // ─► obj2 (b:2) + const obj1 = { ...reqObj1, spaces, inboundReferences: [] }; + const obj2 = { ...reqObj2, spaces: [], inboundReferences: [] }; // non-multi-namespace types and hidden types will be returned with an empty spaces array + const obj3 = { type: 'c', id: '3', spaces, ...getInboundRefsFrom(obj1) }; + const obj4 = { type: 'd', id: '4', spaces, ...getInboundRefsFrom(obj1) }; + const obj5 = { + type: 'c', + id: '5', + spaces: ['*'], + ...getInboundRefsFrom(obj3, { type: 'c', id: '6' }), + }; + const obj6 = { + type: 'c', + id: '6', + spaces, + ...getInboundRefsFrom(obj4, { type: 'c', id: '8' }), + }; + const obj7 = { type: 'c', id: '7', spaces, ...getInboundRefsFrom(obj4) }; + const obj8 = { type: 'c', id: '8', spaces, ...getInboundRefsFrom(obj5) }; + + beforeEach(() => { + clientOpts.baseClient.collectMultiNamespaceReferences.mockResolvedValueOnce({ + objects: [obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8], + }); + }); + + test(`with purpose 'collectMultiNamespaceReferences'`, async () => { + mockEnsureAuthorized.mockResolvedValue({ + status: 'partially_authorized', + typeActionMap: new Map() + .set('a', { bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] } }) + .set('b', { bulk_get: { authorizedSpaces: [spaceX] } }) + .set('c', { bulk_get: { authorizedSpaces: [spaceX] } }), + // the user is not authorized to read type 'd' + }); + + const options = { namespace: spaceX }; // spaceX is the current space + const result = await client.collectMultiNamespaceReferences([reqObj1, reqObj2], options); + expect(result).toEqual({ + objects: [ + obj1, // obj1's spaces array is not redacted because the user is globally authorized to access it + obj2, // obj2 has an empty spaces array (see above) + { ...obj3, spaces: [spaceX, '?', '?'] }, + { ...obj4, spaces: [], isMissing: true }, // obj4 is marked as Missing because the user was not authorized to access it + obj5, // obj5's spaces array is not redacted, because it exists in All Spaces + // obj7 is not included at all because the user was not authorized to access its inbound reference (obj4) + { ...obj8, spaces: [spaceX, '?', '?'] }, + { ...obj6, spaces: [spaceX, '?', '?'], ...getInboundRefsFrom(obj8) }, // obj6 is at the back of the list and its inboundReferences array is redacted because the user is not authorized to access one of its inbound references, obj4 + ], + }); + expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); + expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledWith( + [reqObj1, reqObj2], + options + ); + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledWith( + expect.any(Object), // dependencies + ['a', 'b', 'c', 'd'], // unique types of the fetched objects + ['bulk_get'], // actions + [spaceX, spaceY, spaceZ], // unique spaces that the fetched objects exist in, along with the current space + { requireFullAuthorization: false } + ); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(5); + expectAuditEvent(AUDIT_ACTION, 'success', obj1); + expectAuditEvent(AUDIT_ACTION, 'success', obj3); + expectAuditEvent(AUDIT_ACTION, 'success', obj5); + expectAuditEvent(AUDIT_ACTION, 'success', obj8); + expectAuditEvent(AUDIT_ACTION, 'success', obj6); + // obj2, obj4, and obj7 are intentionally excluded from the audit record because we did not return any information about them to the user + }); + + test(`with purpose 'updateObjectsSpaces'`, async () => { + mockEnsureAuthorized.mockResolvedValue({ + status: 'partially_authorized', + typeActionMap: new Map() + .set('a', { + share_to_space: { authorizedSpaces: [spaceX] }, + bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, + // Even though the user can only share type 'a' in spaceX, we won't redact spaceY or spaceZ because the user has global read privileges + }) + .set('b', { + share_to_space: { authorizedSpaces: [spaceX] }, + bulk_get: { authorizedSpaces: [spaceX, spaceY] }, + }) + .set('c', { + share_to_space: { authorizedSpaces: [spaceX] }, + bulk_get: { authorizedSpaces: [spaceX, spaceY] }, + // Even though the user can only share type 'c' in spaceX, we won't redact spaceY because the user has read privileges there + }), + // the user is not authorized to read or share type 'd' + }); + + const options = { namespace: spaceX, purpose: 'updateObjectsSpaces' as const }; // spaceX is the current space + const result = await client.collectMultiNamespaceReferences([reqObj1, reqObj2], options); + expect(result).toEqual({ + objects: [ + obj1, // obj1's spaces array is not redacted because the user is globally authorized to access it + obj2, // obj2 has an empty spaces array (see above) + { ...obj3, spaces: [spaceX, spaceY, '?'] }, + { ...obj4, spaces: [], isMissing: true }, // obj4 is marked as Missing because the user was not authorized to access it + obj5, // obj5's spaces array is not redacted, because it exists in All Spaces + // obj7 is not included at all because the user was not authorized to access its inbound reference (obj4) + { ...obj8, spaces: [spaceX, spaceY, '?'] }, + { ...obj6, spaces: [spaceX, spaceY, '?'], ...getInboundRefsFrom(obj8) }, // obj6 is at the back of the list and its inboundReferences array is redacted because the user is not authorized to access one of its inbound references, obj4 + ], + }); + expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); + expect(clientOpts.baseClient.collectMultiNamespaceReferences).toHaveBeenCalledWith( + [reqObj1, reqObj2], + options + ); + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledWith( + expect.any(Object), // dependencies + ['a', 'b', 'c', 'd'], // unique types of the fetched objects + ['bulk_get', 'share_to_space'], // actions + [spaceX, spaceY, spaceZ], // unique spaces that the fetched objects exist in, along with the current space + { requireFullAuthorization: false } + ); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(5); + expectAuditEvent(AUDIT_ACTION, 'success', obj1); + expectAuditEvent(AUDIT_ACTION, 'success', obj3); + expectAuditEvent(AUDIT_ACTION, 'success', obj5); + expectAuditEvent(AUDIT_ACTION, 'success', obj8); + expectAuditEvent(AUDIT_ACTION, 'success', obj6); + // obj2, obj4, and obj7 are intentionally excluded from the audit record because we did not return any information about them to the user + }); + }); +}); + +describe('#updateObjectsSpaces', () => { + const AUDIT_ACTION = 'saved_object_update_objects_spaces'; + const spaceA = 'space-a'; + const spaceB = 'space-b'; + const spaceC = 'space-c'; + const spaceD = 'space-d'; + const obj1 = { type: 'x', id: '1' }; + const obj2 = { type: 'y', id: '2' }; + const obj3 = { type: 'z', id: '3' }; + const obj4 = { type: 'z', id: '4' }; + const obj5 = { type: 'z', id: '5' }; + + describe('errors', () => { + test(`throws an error if the base client bulkGet operation fails`, async () => { + clientOpts.baseClient.bulkGet.mockRejectedValue(new Error('Oh no!')); + await expect(() => + client.updateObjectsSpaces([obj1], [spaceA], [spaceB], { namespace: spaceC }) + ).rejects.toThrowError('Oh no!'); + expect(clientOpts.baseClient.bulkGet).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.log).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError and adds audit events when unauthorized`, async () => { + clientOpts.baseClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { ...obj1, namespaces: [spaceB, spaceC, spaceD] }, + { ...obj2, namespaces: [spaceB, spaceC, spaceD] }, + { ...obj3, namespaces: [spaceB, spaceC, spaceD] }, + ] as SavedObject[], + }); + // This fails because the user is not authorized to share_to_space type 'z' in the current space. + mockEnsureAuthorized.mockResolvedValue({ + status: 'partially_authorized', + typeActionMap: new Map() + .set('x', { + bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, + share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('y', { + bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC, spaceD] }, + share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC, spaceD] }, + }) + .set('z', { + bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC] }, + share_to_space: { authorizedSpaces: [spaceA, spaceB] }, + }), + }); + + const objects = [obj1, obj2, obj3]; + const spacesToAdd = [spaceA]; + const spacesToRemove = [spaceB]; + const options = { namespace: spaceC }; // spaceC is the current space + await expect(() => + client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options) + ).rejects.toThrowError(clientOpts.forbiddenError); + expect(clientOpts.baseClient.bulkGet).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(3); + expectAuditEvent(AUDIT_ACTION, 'failure', obj1); + expectAuditEvent(AUDIT_ACTION, 'failure', obj2); + expectAuditEvent(AUDIT_ACTION, 'failure', obj3); + expect(clientOpts.baseClient.updateObjectsSpaces).not.toHaveBeenCalled(); + }); + + test(`throws an error if the base client updateObjectsSpaces operation fails`, async () => { + clientOpts.baseClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { ...obj1, namespaces: [spaceB, spaceC, spaceD] }, + { ...obj2, namespaces: [spaceB, spaceC, spaceD] }, + { ...obj3, namespaces: [spaceB, spaceC, spaceD] }, + ] as SavedObject[], + }); + mockEnsureAuthorized.mockResolvedValue({ + status: 'partially_authorized', + typeActionMap: new Map() + .set('x', { + bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, + share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('y', { + bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC, spaceD] }, + share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC] }, + }) + .set('z', { + bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC] }, + share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC] }, + }), + }); + clientOpts.baseClient.updateObjectsSpaces.mockRejectedValue(new Error('Oh no!')); + + const objects = [obj1, obj2, obj3]; + const spacesToAdd = [spaceA]; + const spacesToRemove = [spaceB]; + const options = { namespace: spaceC }; // spaceC is the current space + await expect(() => + client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options) + ).rejects.toThrowError('Oh no!'); + expect(clientOpts.baseClient.bulkGet).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(3); + expectAuditEvent(AUDIT_ACTION, 'unknown', obj1); + expectAuditEvent(AUDIT_ACTION, 'unknown', obj2); + expectAuditEvent(AUDIT_ACTION, 'unknown', obj3); + expect(clientOpts.baseClient.updateObjectsSpaces).toHaveBeenCalledTimes(1); + }); + }); + + test(`checks privileges, filters/redacts objects correctly, and records audit events`, async () => { + const bulkGetResults = [ + { ...obj1, namespaces: [spaceB, spaceC, spaceD], version: 'v1' }, + { ...obj2, namespaces: [spaceB, spaceC, spaceD], version: 'v2' }, + { ...obj3, namespaces: [spaceB, spaceC, spaceD], version: 'v3' }, + { ...obj4, namespaces: ['*'], version: 'v4' }, // obj4 exists in all spaces + { ...obj5, namespaces: [spaceB, spaceC, spaceD], version: 'v5' }, + ] as SavedObject[]; + clientOpts.baseClient.bulkGet.mockResolvedValue({ saved_objects: bulkGetResults }); + mockEnsureAuthorized.mockResolvedValue({ + status: 'partially_authorized', + typeActionMap: new Map() + .set('x', { + bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, + share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }) + .set('y', { + bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC, spaceD] }, + share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC] }, + }) + .set('z', { + bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC] }, // the user is not authorized to bulkGet type 'z' in spaceD, so it will be redacted from the results + share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC] }, + }), + }); + clientOpts.baseClient.updateObjectsSpaces.mockResolvedValue({ + objects: [ + // Each object was added to spaceA and removed from spaceB + { ...obj1, spaces: [spaceA, spaceC, spaceD] }, + { ...obj2, spaces: [spaceA, spaceC, spaceD] }, + { ...obj3, spaces: [spaceA, spaceC, spaceD] }, + { ...obj4, spaces: ['*', spaceA] }, // even though this object exists in all spaces, we won't pass '*' to ensureAuthorized + { ...obj5, spaces: [], error: new Error('Oh no!') }, // we encountered an error when attempting to update obj5 + ] as SavedObjectsUpdateObjectsSpacesResponseObject[], + }); + + const objects = [obj1, obj2, obj3, obj4, obj5]; + const spacesToAdd = [spaceA]; + const spacesToRemove = [spaceB]; + const options = { namespace: spaceC }; // spaceC is the current space + const result = await client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options); + expect(result).toEqual({ + objects: [ + { ...obj1, spaces: [spaceA, spaceC, spaceD] }, // obj1's spaces array is not redacted because the user is globally authorized to access it + { ...obj2, spaces: [spaceA, spaceC, spaceD] }, // obj2's spaces array is not redacted because the user is authorized to access it in each space + { ...obj3, spaces: [spaceA, spaceC, '?'] }, // obj3's spaces array is redacted because the user is not authorized to access it in spaceD + { ...obj4, spaces: ['*', spaceA] }, + { ...obj5, spaces: [], error: new Error('Oh no!') }, + ], + }); + + expect(clientOpts.baseClient.bulkGet).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledWith( + expect.any(Object), // dependencies + ['x', 'y', 'z'], // unique types of the fetched objects + ['bulk_get', 'share_to_space'], // actions + [spaceC, spaceA, spaceB, spaceD], // unique spaces of: the current space, spacesToAdd, spacesToRemove, and spaces that the fetched objects exist in (excludes '*') + { requireFullAuthorization: false } + ); + expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(5); + expectAuditEvent(AUDIT_ACTION, 'unknown', obj1); + expectAuditEvent(AUDIT_ACTION, 'unknown', obj2); + expectAuditEvent(AUDIT_ACTION, 'unknown', obj3); + expectAuditEvent(AUDIT_ACTION, 'unknown', obj4); + expectAuditEvent(AUDIT_ACTION, 'unknown', obj5); + expect(clientOpts.baseClient.updateObjectsSpaces).toHaveBeenCalledTimes(1); + expect(clientOpts.baseClient.updateObjectsSpaces).toHaveBeenCalledWith( + bulkGetResults.map(({ namespaces: spaces, ...otherAttrs }) => ({ spaces, ...otherAttrs })), + spacesToAdd, + spacesToRemove, + options + ); + }); + + test(`checks privileges for the global resource when spacesToAdd includes '*'`, async () => { + const bulkGetResults = [{ ...obj1, namespaces: [spaceA], version: 'v1' }] as SavedObject[]; + clientOpts.baseClient.bulkGet.mockResolvedValue({ saved_objects: bulkGetResults }); + mockEnsureAuthorized.mockResolvedValue({ + status: 'fully_authorized', + typeActionMap: new Map().set('x', { + bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, + share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }), + }); + clientOpts.baseClient.updateObjectsSpaces.mockResolvedValue({ + objects: [ + // The object was removed from spaceA and added to '*' + { ...obj1, spaces: ['*'] }, + ] as SavedObjectsUpdateObjectsSpacesResponseObject[], + }); + + const objects = [obj1]; + const spacesToAdd = ['*']; + const spacesToRemove = [spaceA]; + const options = { namespace: spaceC }; // spaceC is the current space + await client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options); + + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledWith( + expect.any(Object), // dependencies + ['x'], // unique types of the fetched objects + ['bulk_get', 'share_to_space'], // actions + [spaceC, '*', spaceA], // unique spaces of: the current space, spacesToAdd, spacesToRemove, and spaces that the fetched objects exist in (excludes '*') + { requireFullAuthorization: false } + ); + }); + + test(`checks privileges for the global resource when spacesToRemove includes '*'`, async () => { + const bulkGetResults = [{ ...obj1, namespaces: ['*'], version: 'v1' }] as SavedObject[]; + clientOpts.baseClient.bulkGet.mockResolvedValue({ saved_objects: bulkGetResults }); + mockEnsureAuthorized.mockResolvedValue({ + status: 'fully_authorized', + typeActionMap: new Map().set('x', { + bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, + share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, + }), + }); + clientOpts.baseClient.updateObjectsSpaces.mockResolvedValue({ + objects: [ + // The object was removed from spaceA and added to '*' + { ...obj1, spaces: ['*'] }, + ] as SavedObjectsUpdateObjectsSpacesResponseObject[], + }); + + const objects = [obj1]; + const spacesToAdd = [spaceA]; + const spacesToRemove = ['*']; + const options = { namespace: spaceC }; // spaceC is the current space + await client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, options); + + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledWith( + expect.any(Object), // dependencies + ['x'], // unique types of the fetched objects + ['bulk_get', 'share_to_space'], // actions + [spaceC, spaceA, '*'], // unique spaces of: the current space, spacesToAdd, spacesToRemove, and spaces that the fetched objects exist in (excludes '*') + { requireFullAuthorization: false } + ); + }); +}); + describe('other', () => { test(`assigns errors from constructor to .errors`, () => { expect(client.errors).toBe(clientOpts.errors); 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 066a720f70721..ef3dcac4c064b 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 @@ -7,7 +7,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import type { - SavedObjectsAddToNamespacesOptions, + SavedObjectReferenceWithContext, SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, @@ -15,13 +15,17 @@ import type { SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsClosePointInTimeOptions, + SavedObjectsCollectMultiNamespaceReferencesObject, + SavedObjectsCollectMultiNamespaceReferencesOptions, + SavedObjectsCollectMultiNamespaceReferencesResponse, SavedObjectsCreateOptions, SavedObjectsCreatePointInTimeFinderDependencies, SavedObjectsCreatePointInTimeFinderOptions, - SavedObjectsDeleteFromNamespacesOptions, SavedObjectsFindOptions, SavedObjectsOpenPointInTimeOptions, SavedObjectsRemoveReferencesToOptions, + SavedObjectsUpdateObjectsSpacesObject, + SavedObjectsUpdateObjectsSpacesOptions, SavedObjectsUpdateOptions, } from 'src/core/server'; @@ -32,6 +36,12 @@ import { SavedObjectAction, savedObjectEvent } from '../audit'; import type { Actions, CheckSavedObjectsPrivileges } from '../authorization'; import type { CheckPrivilegesResponse } from '../authorization/types'; import type { SpacesService } from '../plugin'; +import type { + EnsureAuthorizedDependencies, + EnsureAuthorizedOptions, + EnsureAuthorizedResult, +} from './ensure_authorized'; +import { ensureAuthorized, getEnsureAuthorizedActionResult } from './ensure_authorized'; interface SecureSavedObjectsClientWrapperOptions { actions: Actions; @@ -51,21 +61,20 @@ interface SavedObjectsNamespaces { saved_objects: SavedObjectNamespaces[]; } -interface EnsureAuthorizedOptions { +interface LegacyEnsureAuthorizedOptions { args?: Record; auditAction?: string; requireFullAuthorization?: boolean; } -interface EnsureAuthorizedResult { +interface LegacyEnsureAuthorizedResult { status: 'fully_authorized' | 'partially_authorized' | 'unauthorized'; - typeMap: Map; + typeMap: Map; } -interface EnsureAuthorizedTypeResult { +interface LegacyEnsureAuthorizedTypeResult { authorizedSpaces: string[]; isGloballyAuthorized?: boolean; } - export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { private readonly actions: Actions; private readonly legacyAuditLogger: PublicMethodsOf; @@ -102,7 +111,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const namespaces = [optionsWithId.namespace, ...(optionsWithId.initialNamespaces || [])]; try { const args = { type, attributes, options: optionsWithId }; - await this.ensureAuthorized(type, 'create', namespaces, { args }); + await this.legacyEnsureAuthorized(type, 'create', namespaces, { args }); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -131,7 +140,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { const args = { objects, options }; const types = this.getUniqueObjectTypes(objects); - await this.ensureAuthorized(types, 'bulk_create', options.namespace, { + await this.legacyEnsureAuthorized(types, 'bulk_create', options.namespace, { args, auditAction: 'checkConflicts', }); @@ -154,7 +163,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ); try { const args = { objects: objectsWithId, options }; - await this.ensureAuthorized( + await this.legacyEnsureAuthorized( this.getUniqueObjectTypes(objectsWithId), 'bulk_create', namespaces, @@ -191,7 +200,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { try { const args = { type, id, options }; - await this.ensureAuthorized(type, 'delete', options.namespace, { args }); + await this.legacyEnsureAuthorized(type, 'delete', options.namespace, { args }); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -230,7 +239,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } const args = { options }; - const { status, typeMap } = await this.ensureAuthorized( + const { status, typeMap } = await this.legacyEnsureAuthorized( options.type, 'find', options.namespaces, @@ -278,7 +287,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { try { const args = { objects, options }; - await this.ensureAuthorized( + await this.legacyEnsureAuthorized( this.getUniqueObjectTypes(objects), 'bulk_get', options.namespace, @@ -318,7 +327,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { try { const args = { type, id, options }; - await this.ensureAuthorized(type, 'get', options.namespace, { args }); + await this.legacyEnsureAuthorized(type, 'get', options.namespace, { args }); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -349,7 +358,10 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { try { const args = { type, id, options }; - await this.ensureAuthorized(type, 'get', options.namespace, { args, auditAction: 'resolve' }); + await this.legacyEnsureAuthorized(type, 'get', options.namespace, { + args, + auditAction: 'resolve', + }); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -386,7 +398,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { try { const args = { type, id, attributes, options }; - await this.ensureAuthorized(type, 'update', options.namespace, { args }); + await this.legacyEnsureAuthorized(type, 'update', options.namespace, { args }); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -409,90 +421,6 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]); } - public async addToNamespaces( - type: string, - id: string, - namespaces: string[], - options: SavedObjectsAddToNamespacesOptions = {} - ) { - const { namespace } = options; - try { - const args = { type, id, namespaces, options }; - // To share an object, the user must have the "share_to_space" permission in each of the destination namespaces. - await this.ensureAuthorized(type, 'share_to_space', namespaces, { - args, - auditAction: 'addToNamespacesCreate', - }); - - // To share an object, the user must also have the "share_to_space" permission in one or more of the source namespaces. Because the - // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "share_to_space" permission in - // the current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation - // will result in a 404 error. - await this.ensureAuthorized(type, 'share_to_space', namespace, { - args, - auditAction: 'addToNamespacesUpdate', - }); - } catch (error) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.ADD_TO_SPACES, - savedObject: { type, id }, - addToSpaces: namespaces, - error, - }) - ); - throw error; - } - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.ADD_TO_SPACES, - outcome: 'unknown', - savedObject: { type, id }, - addToSpaces: namespaces, - }) - ); - - const response = await this.baseClient.addToNamespaces(type, id, namespaces, options); - return await this.redactSavedObjectNamespaces(response, [namespace, ...namespaces]); - } - - public async deleteFromNamespaces( - type: string, - id: string, - namespaces: string[], - options: SavedObjectsDeleteFromNamespacesOptions = {} - ) { - try { - const args = { type, id, namespaces, options }; - // To un-share an object, the user must have the "share_to_space" permission in each of the target namespaces. - await this.ensureAuthorized(type, 'share_to_space', namespaces, { - args, - auditAction: 'deleteFromNamespaces', - }); - } catch (error) { - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.DELETE_FROM_SPACES, - savedObject: { type, id }, - deleteFromSpaces: namespaces, - error, - }) - ); - throw error; - } - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.DELETE_FROM_SPACES, - outcome: 'unknown', - savedObject: { type, id }, - deleteFromSpaces: namespaces, - }) - ); - - const response = await this.baseClient.deleteFromNamespaces(type, id, namespaces, options); - return await this.redactSavedObjectNamespaces(response, namespaces); - } - public async bulkUpdate( objects: Array> = [], options: SavedObjectsBaseOptions = {} @@ -505,9 +433,14 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const namespaces = [options?.namespace, ...objectNamespaces]; try { const args = { objects, options }; - await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, { - args, - }); + await this.legacyEnsureAuthorized( + this.getUniqueObjectTypes(objects), + 'bulk_update', + namespaces, + { + args, + } + ); } catch (error) { objects.forEach(({ type, id }) => this.auditLogger.log( @@ -541,7 +474,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { try { const args = { type, id, options }; - await this.ensureAuthorized(type, 'delete', options.namespace, { + await this.legacyEnsureAuthorized(type, 'delete', options.namespace, { args, auditAction: 'removeReferences', }); @@ -573,7 +506,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { try { const args = { type, options }; - await this.ensureAuthorized(type, 'open_point_in_time', options?.namespace, { + await this.legacyEnsureAuthorized(type, 'open_point_in_time', options?.namespace, { args, // Partial authorization is acceptable in this case because this method is only designed // to be used with `find`, which already allows for partial authorization. @@ -618,20 +551,254 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.baseClient.closePointInTime(id, options); } - public createPointInTimeFinder( + public createPointInTimeFinder( findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies ) { // We don't need to perform an authorization check here or add an audit log, because // `createPointInTimeFinder` is simply a helper that calls `find`, `openPointInTimeForType`, // and `closePointInTime` internally, so authz checks and audit logs will already be applied. - return this.baseClient.createPointInTimeFinder(findOptions, { + return this.baseClient.createPointInTimeFinder(findOptions, { client: this, // Include dependencies last so that subsequent SO client wrappers have their settings applied. ...dependencies, }); } + public async collectMultiNamespaceReferences( + objects: SavedObjectsCollectMultiNamespaceReferencesObject[], + options: SavedObjectsCollectMultiNamespaceReferencesOptions = {} + ): Promise { + const currentSpaceId = SavedObjectsUtils.namespaceIdToString(options.namespace); // We need this whether the Spaces plugin is enabled or not. + + // We don't know the space(s) that each object exists in, so we'll collect the objects and references first, then check authorization. + const response = await this.baseClient.collectMultiNamespaceReferences(objects, options); + const uniqueTypes = this.getUniqueObjectTypes(response.objects); + const uniqueSpaces = this.getUniqueSpaces( + currentSpaceId, + ...response.objects.flatMap(({ spaces, spacesWithMatchingAliases = [] }) => + spaces.concat(spacesWithMatchingAliases) + ) + ); + + const { typeActionMap } = await this.ensureAuthorized( + uniqueTypes, + options.purpose === 'updateObjectsSpaces' ? ['bulk_get', 'share_to_space'] : ['bulk_get'], + uniqueSpaces, + { requireFullAuthorization: false } + ); + + // The user must be authorized to access every requested object in the current space. + // Note: non-multi-namespace object types will have an empty spaces array. + const authAction = options.purpose === 'updateObjectsSpaces' ? 'share_to_space' : 'bulk_get'; + try { + this.ensureAuthorizedInAllSpaces(objects, authAction, typeActionMap, [currentSpaceId]); + } catch (error) { + objects.forEach(({ type, id }) => + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.COLLECT_MULTINAMESPACE_REFERENCES, + savedObject: { type, id }, + error, + }) + ) + ); + throw error; + } + + // The user is authorized to access all of the requested objects in the space(s) that they exist in. + // Now: 1. omit any result objects that the user has no access to, 2. for the rest, redact any space(s) that the user is not authorized + // for, and 3. create audit records for any objects that will be returned to the user. + const requestedObjectsSet = objects.reduce( + (acc, { type, id }) => acc.add(`${type}:${id}`), + new Set() + ); + const retrievedObjectsSet = response.objects.reduce( + (acc, { type, id }) => acc.add(`${type}:${id}`), + new Set() + ); + const traversedObjects = new Set(); + const filteredObjectsMap = new Map(); + const getIsAuthorizedForInboundReference = (inbound: { type: string; id: string }) => { + const found = filteredObjectsMap.get(`${inbound.type}:${inbound.id}`); + return found && !found.isMissing; // If true, this object can be linked back to one of the requested objects + }; + let objectsToProcess = [...response.objects]; + while (objectsToProcess.length > 0) { + const obj = objectsToProcess.shift()!; + const { type, id, spaces, inboundReferences } = obj; + const objKey = `${type}:${id}`; + traversedObjects.add(objKey); + // Is the user authorized to access this object in all required space(s)? + const isAuthorizedForObject = isAuthorizedForObjectInAllSpaces( + type, + authAction, + typeActionMap, + [currentSpaceId] + ); + // Redact the inbound references so we don't leak any info about other objects that the user is not authorized to access + const redactedInboundReferences = inboundReferences.filter((inbound) => { + if (inbound.type === type && inbound.id === id) { + // circular reference, don't redact it + return true; + } + return getIsAuthorizedForInboundReference(inbound); + }); + // If the user is not authorized to access at least one inbound reference of this object, then we should omit this object. + const isAuthorizedForGraph = + requestedObjectsSet.has(objKey) || // If true, this is one of the requested objects, and we checked authorization above + redactedInboundReferences.some(getIsAuthorizedForInboundReference); + + if (isAuthorizedForObject && isAuthorizedForGraph) { + if (spaces.length) { + // Don't generate audit records for "empty results" with zero spaces (requested object was a non-multi-namespace type or hidden type) + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.COLLECT_MULTINAMESPACE_REFERENCES, + savedObject: { type, id }, + }) + ); + } + filteredObjectsMap.set(objKey, obj); + } else if (!isAuthorizedForObject && isAuthorizedForGraph) { + filteredObjectsMap.set(objKey, { ...obj, spaces: [], isMissing: true }); + } else if (isAuthorizedForObject && !isAuthorizedForGraph) { + const hasUntraversedInboundReferences = inboundReferences.some( + (ref) => + !traversedObjects.has(`${ref.type}:${ref.id}`) && + retrievedObjectsSet.has(`${ref.type}:${ref.id}`) + ); + + if (hasUntraversedInboundReferences) { + // this object has inbound reference(s) that we haven't traversed yet; bump it to the back of the list + objectsToProcess = [...objectsToProcess, obj]; + } else { + // There should never be a missing inbound reference. + // If there is, then something has gone terribly wrong. + const missingInboundReference = inboundReferences.find( + (ref) => + !traversedObjects.has(`${ref.type}:${ref.id}`) && + !retrievedObjectsSet.has(`${ref.type}:${ref.id}`) + ); + + if (missingInboundReference) { + throw new Error( + `Unexpected inbound reference to "${missingInboundReference.type}:${missingInboundReference.id}"` + ); + } + } + } + } + + const filteredAndRedactedObjects = [...filteredObjectsMap.values()].map((obj) => { + const { type, id, spaces, spacesWithMatchingAliases, inboundReferences } = obj; + // Redact the inbound references so we don't leak any info about other objects that the user is not authorized to access + const redactedInboundReferences = inboundReferences.filter((inbound) => { + if (inbound.type === type && inbound.id === id) { + // circular reference, don't redact it + return true; + } + return getIsAuthorizedForInboundReference(inbound); + }); + const redactedSpaces = getRedactedSpaces(type, 'bulk_get', typeActionMap, spaces); + const redactedSpacesWithMatchingAliases = + spacesWithMatchingAliases && + getRedactedSpaces(type, 'bulk_get', typeActionMap, spacesWithMatchingAliases); + return { + ...obj, + spaces: redactedSpaces, + ...(redactedSpacesWithMatchingAliases && { + spacesWithMatchingAliases: redactedSpacesWithMatchingAliases, + }), + inboundReferences: redactedInboundReferences, + }; + }); + + return { + objects: filteredAndRedactedObjects, + }; + } + + public async updateObjectsSpaces( + objects: SavedObjectsUpdateObjectsSpacesObject[], + spacesToAdd: string[], + spacesToRemove: string[], + options: SavedObjectsUpdateObjectsSpacesOptions = {} + ) { + const { namespace } = options; + const currentSpaceId = SavedObjectsUtils.namespaceIdToString(namespace); // We need this whether the Spaces plugin is enabled or not. + + const allSpacesSet = new Set([currentSpaceId, ...spacesToAdd, ...spacesToRemove]); + const bulkGetResponse = await this.baseClient.bulkGet(objects, { namespace }); + const objectsToUpdate = objects.map(({ type, id }, i) => { + const { namespaces: spaces = [], version } = bulkGetResponse.saved_objects[i]; + // If 'namespaces' is undefined, the object was not found (or it is namespace-agnostic). + // Either way, we will pass in an empty 'spaces' array to the base client, which will cause it to skip this object. + for (const space of spaces) { + if (space !== ALL_SPACES_ID) { + // If this is a specific space, add it to the spaces we'll check privileges for (don't accidentally check for global privileges) + allSpacesSet.add(space); + } + } + return { type, id, spaces, version }; + }); + + const uniqueTypes = this.getUniqueObjectTypes(objects); + const { typeActionMap } = await this.ensureAuthorized( + uniqueTypes, + ['bulk_get', 'share_to_space'], + Array.from(allSpacesSet), + { requireFullAuthorization: false } + ); + + const addToSpaces = spacesToAdd.length ? spacesToAdd : undefined; + const deleteFromSpaces = spacesToRemove.length ? spacesToRemove : undefined; + try { + // The user must be authorized to share every requested object in each of: the current space, spacesToAdd, and spacesToRemove. + const spaces = this.getUniqueSpaces(currentSpaceId, ...spacesToAdd, ...spacesToRemove); + this.ensureAuthorizedInAllSpaces(objects, 'share_to_space', typeActionMap, spaces); + } catch (error) { + objects.forEach(({ type, id }) => + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.UPDATE_OBJECTS_SPACES, + savedObject: { type, id }, + addToSpaces, + deleteFromSpaces, + error, + }) + ) + ); + throw error; + } + for (const { type, id } of objectsToUpdate) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.UPDATE_OBJECTS_SPACES, + outcome: 'unknown', + savedObject: { type, id }, + addToSpaces, + deleteFromSpaces, + }) + ); + } + + const response = await this.baseClient.updateObjectsSpaces( + objectsToUpdate, + spacesToAdd, + spacesToRemove, + { namespace } + ); + // Now that we have updated the objects' spaces, redact any spaces that the user is not authorized to see from the response. + const redactedObjects = response.objects.map((obj) => { + const { type, spaces } = obj; + const redactedSpaces = getRedactedSpaces(type, 'bulk_get', typeActionMap, spaces); + return { ...obj, spaces: redactedSpaces }; + }); + + return { objects: redactedObjects }; + } + private async checkPrivileges( actions: string | string[], namespaceOrNamespaces?: string | Array @@ -643,12 +810,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } } - private async ensureAuthorized( + private async legacyEnsureAuthorized( typeOrTypes: string | string[], action: string, namespaceOrNamespaces: undefined | string | Array, - options: EnsureAuthorizedOptions = {} - ): Promise { + options: LegacyEnsureAuthorizedOptions = {} + ): Promise { const { args, auditAction = action, requireFullAuthorization = true } = options; const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; const actionsToTypesMap = new Map( @@ -663,7 +830,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ).sort() as string[]; const missingPrivileges = this.getMissingPrivileges(privileges); - const typeMap = privileges.kibana.reduce>( + const typeMap = privileges.kibana.reduce>( (acc, { resource, privilege, authorized }) => { if (!authorized) { return acc; @@ -724,6 +891,45 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } } + /** Unlike `legacyEnsureAuthorized`, this accepts multiple actions, and it does not utilize legacy audit logging */ + private async ensureAuthorized( + types: string[], + actions: T[], + namespaces: string[], + options?: EnsureAuthorizedOptions + ) { + const ensureAuthorizedDependencies: EnsureAuthorizedDependencies = { + actions: this.actions, + errors: this.errors, + checkSavedObjectsPrivilegesAsCurrentUser: this.checkSavedObjectsPrivilegesAsCurrentUser, + }; + return ensureAuthorized(ensureAuthorizedDependencies, types, actions, namespaces, options); + } + + /** + * If `ensureAuthorized` was called with `requireFullAuthorization: false`, this can be used with the result to ensure that a given + * array of objects are authorized in the required space(s). + */ + private ensureAuthorizedInAllSpaces( + objects: Array<{ type: string }>, + action: T, + typeActionMap: EnsureAuthorizedResult['typeActionMap'], + spaces: string[] + ) { + const uniqueTypes = uniq(objects.map(({ type }) => type)); + const unauthorizedTypes = new Set(); + for (const type of uniqueTypes) { + if (!isAuthorizedForObjectInAllSpaces(type, action, typeActionMap, spaces)) { + unauthorizedTypes.add(type); + } + } + if (unauthorizedTypes.size > 0) { + const targetTypes = Array.from(unauthorizedTypes).sort().join(','); + const msg = `Unable to ${action} ${targetTypes}`; + throw this.errors.decorateForbiddenError(new Error(msg)); + } + } + private getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { return privileges.kibana .filter(({ authorized }) => !authorized) @@ -734,6 +940,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return uniq(objects.map((o) => o.type)); } + /** + * Given a list of spaces, returns a unique array of spaces. + * Excludes `'*'`, which is an identifier for All Spaces but is not an actual space. + */ + private getUniqueSpaces(...spaces: string[]) { + const set = new Set(spaces); + set.delete(ALL_SPACES_ID); + return Array.from(set); + } + private async getNamespacesPrivilegeMap( namespaces: string[], previouslyAuthorizedSpaceIds: string[] @@ -854,3 +1070,33 @@ function namespaceComparator(a: string, b: string) { } return A > B ? 1 : A < B ? -1 : 0; } + +function isAuthorizedForObjectInAllSpaces( + objectType: string, + action: T, + typeActionMap: EnsureAuthorizedResult['typeActionMap'], + spacesToAuthorizeFor: string[] +) { + const actionResult = getEnsureAuthorizedActionResult(objectType, action, typeActionMap); + const { authorizedSpaces, isGloballyAuthorized } = actionResult; + const authorizedSpacesSet = new Set(authorizedSpaces); + return ( + isGloballyAuthorized || spacesToAuthorizeFor.every((space) => authorizedSpacesSet.has(space)) + ); +} + +function getRedactedSpaces( + objectType: string, + action: T, + typeActionMap: EnsureAuthorizedResult['typeActionMap'], + spacesToRedact: string[] +) { + const actionResult = getEnsureAuthorizedActionResult(objectType, action, typeActionMap); + const { authorizedSpaces, isGloballyAuthorized } = actionResult; + const authorizedSpacesSet = new Set(authorizedSpaces); + return spacesToRedact + .map((x) => + isGloballyAuthorized || x === ALL_SPACES_ID || authorizedSpacesSet.has(x) ? x : UNKNOWN_SPACE + ) + .sort(namespaceComparator); +} diff --git a/x-pack/plugins/spaces/common/index.ts b/x-pack/plugins/spaces/common/index.ts index 38a452a82a6f9..9935d8055ec30 100644 --- a/x-pack/plugins/spaces/common/index.ts +++ b/x-pack/plugins/spaces/common/index.ts @@ -8,4 +8,4 @@ export { isReservedSpace } from './is_reserved_space'; export { MAX_SPACE_INITIALS, SPACE_SEARCH_COUNT_THRESHOLD, ENTER_SPACE_PATH } from './constants'; export { addSpaceIdToPath, getSpaceIdFromPath } from './lib/spaces_url_parser'; -export { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from './types'; +export type { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from './types'; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index b5b7c7c657b1b..4ec90b7e3826b 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -280,7 +280,7 @@ describe('ShareToSpaceFlyout', () => { it('handles errors thrown from shareSavedObjectsAdd API call', async () => { const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); - mockSpacesManager.shareSavedObjectAdd.mockRejectedValue( + mockSpacesManager.updateSavedObjectsSpaces.mockRejectedValue( Boom.serverUnavailable('Something bad happened') ); @@ -303,39 +303,7 @@ describe('ShareToSpaceFlyout', () => { wrapper.update(); }); - expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); - expect(mockSpacesManager.shareSavedObjectRemove).not.toHaveBeenCalled(); - expect(mockToastNotifications.addError).toHaveBeenCalled(); - }); - - it('handles errors thrown from shareSavedObjectsRemove API call', async () => { - const { wrapper, mockSpacesManager, mockToastNotifications } = await setup(); - - mockSpacesManager.shareSavedObjectRemove.mockRejectedValue( - Boom.serverUnavailable('Something bad happened') - ); - - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); - expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - - // Using props callback instead of simulating clicks, - // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects - const spaceSelector = wrapper.find(SelectableSpacesControl); - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); - - expect(mockSpacesManager.shareSavedObjectAdd).toHaveBeenCalled(); - expect(mockSpacesManager.shareSavedObjectRemove).toHaveBeenCalled(); + expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalled(); expect(mockToastNotifications.addError).toHaveBeenCalled(); }); @@ -369,9 +337,11 @@ describe('ShareToSpaceFlyout', () => { }); const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); - expect(shareSavedObjectRemove).not.toHaveBeenCalled(); + expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith( + [{ type, id }], + ['space-2', 'space-3'], + [] + ); expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); expect(mockToastNotifications.addError).not.toHaveBeenCalled(); @@ -408,9 +378,11 @@ describe('ShareToSpaceFlyout', () => { }); const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).not.toHaveBeenCalled(); - expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith( + [{ type, id }], + [], + ['space-1'] + ); expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); expect(mockToastNotifications.addError).not.toHaveBeenCalled(); @@ -447,11 +419,13 @@ describe('ShareToSpaceFlyout', () => { }); const { type, id } = savedObjectToShare; - const { shareSavedObjectAdd, shareSavedObjectRemove } = mockSpacesManager; - expect(shareSavedObjectAdd).toHaveBeenCalledWith({ type, id }, ['space-2', 'space-3']); - expect(shareSavedObjectRemove).toHaveBeenCalledWith({ type, id }, ['space-1']); + expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith( + [{ type, id }], + ['space-2', 'space-3'], + ['space-1'] + ); - expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(2); + expect(mockToastNotifications.addSuccess).toHaveBeenCalledTimes(1); expect(mockToastNotifications.addError).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index fc5d42df8af5e..d8fc0f299d8e6 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -46,8 +46,17 @@ const LazyCopyToSpaceFlyout = lazy(() => ); const ALL_SPACES_TARGET = i18n.translate('xpack.spaces.shareToSpace.allSpacesTarget', { - defaultMessage: 'all', + defaultMessage: 'all spaces', }); +function getSpacesTargetString(spaces: string[]) { + if (spaces.includes(ALL_SPACES_ID)) { + return ALL_SPACES_TARGET; + } + return i18n.translate('xpack.spaces.shareToSpace.spacesTarget', { + defaultMessage: '{spacesCount, plural, one {# space} other {# spaces}}', + values: { spacesCount: spaces.length }, + }); +} const arraysAreEqual = (a: unknown[], b: unknown[]) => a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); @@ -59,44 +68,46 @@ function createDefaultChangeSpacesHandler( ) { return async (spacesToAdd: string[], spacesToRemove: string[]) => { const { type, id, title } = object; + const objects = [{ type, id }]; const toastTitle = i18n.translate('xpack.spaces.shareToSpace.shareSuccessTitle', { values: { objectNoun: object.noun }, defaultMessage: 'Updated {objectNoun}', + description: `Object noun can be plural or singular, examples: "Updated objects", "Updated job"`, }); + await spacesManager.updateSavedObjectsSpaces(objects, spacesToAdd, spacesToRemove); + const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); - if (spacesToAdd.length > 0) { - await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); - const spaceTargets = isSharedToAllSpaces ? ALL_SPACES_TARGET : `${spacesToAdd.length}`; - const toastText = - !isSharedToAllSpaces && spacesToAdd.length === 1 - ? i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextSingular', { - defaultMessage: `'{object}' was added to 1 space.`, - values: { object: title }, - }) - : i18n.translate('xpack.spaces.shareToSpace.shareAddSuccessTextPlural', { - defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, - values: { object: title, spaceTargets }, - }); - toastNotifications.addSuccess({ title: toastTitle, text: toastText }); - } - if (spacesToRemove.length > 0) { - await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); - const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); - const spaceTargets = isUnsharedFromAllSpaces ? ALL_SPACES_TARGET : `${spacesToRemove.length}`; - const toastText = - !isUnsharedFromAllSpaces && spacesToRemove.length === 1 - ? i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextSingular', { - defaultMessage: `'{object}' was removed from 1 space.`, - values: { object: title }, - }) - : i18n.translate('xpack.spaces.shareToSpace.shareRemoveSuccessTextPlural', { - defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, - values: { object: title, spaceTargets }, - }); - if (!isSharedToAllSpaces) { - toastNotifications.addSuccess({ title: toastTitle, text: toastText }); - } + let toastText: string; + if (spacesToAdd.length > 0 && spacesToRemove.length > 0 && !isSharedToAllSpaces) { + toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessAddRemoveText', { + defaultMessage: `'{object}' was added to {spacesTargetAdd} and removed from {spacesTargetRemove}.`, // TODO: update to include # of references and/or # of tags + values: { + object: title, + spacesTargetAdd: getSpacesTargetString(spacesToAdd), + spacesTargetRemove: getSpacesTargetString(spacesToRemove), + }, + description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget...' inputs. Example strings: "'Finance dashboard' was added to 1 space and removed from 2 spaces.", "'Finance dashboard' was added to 3 spaces and removed from all spaces."`, + }); + } else if (spacesToAdd.length > 0) { + toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessAddText', { + defaultMessage: `'{object}' was added to {spacesTarget}.`, // TODO: update to include # of references and/or # of tags + values: { + object: title, + spacesTarget: getSpacesTargetString(spacesToAdd), + }, + description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was added to 1 space.", "'Finance dashboard' was added to all spaces."`, + }); + } else { + toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessRemoveText', { + defaultMessage: `'{object}' was removed from {spacesTarget}.`, // TODO: update to include # of references and/or # of tags + values: { + object: title, + spacesTarget: getSpacesTargetString(spacesToRemove), + }, + description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was removed from 1 space.", "'Finance dashboard' was removed from all spaces."`, + }); } + toastNotifications.addSuccess({ title: toastTitle, text: toastText }); }; } @@ -148,9 +159,11 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { spaces: ShareToSpaceTarget[]; }>({ isLoading: true, spaces: [] }); useEffect(() => { - const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObjectTarget.type); - Promise.all([shareToSpacesDataPromise, getPermissions]) - .then(([shareToSpacesData, permissions]) => { + const { type, id } = savedObjectTarget; + const getShareableReferences = spacesManager.getShareableReferences([{ type, id }]); // NOTE: not used yet, this is just included so you can see the request/response in Dev Tools + const getPermissions = spacesManager.getShareSavedObjectPermissions(type); + Promise.all([shareToSpacesDataPromise, getShareableReferences, getPermissions]) + .then(([shareToSpacesData, shareableReferences, permissions]) => { const activeSpaceId = !enableSpaceAgnosticBehavior && shareToSpacesData.activeSpaceId; const selectedSpaceIds = savedObjectTarget.namespaces.filter( (spaceId) => spaceId !== activeSpaceId diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts index ccb475369104a..39c06a2bc874d 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts @@ -22,8 +22,8 @@ function createSpacesManagerMock() { updateSpace: jest.fn().mockResolvedValue(undefined), deleteSpace: jest.fn().mockResolvedValue(undefined), copySavedObjects: jest.fn().mockResolvedValue(undefined), - shareSavedObjectAdd: jest.fn().mockResolvedValue(undefined), - shareSavedObjectRemove: jest.fn().mockResolvedValue(undefined), + getShareableReferences: jest.fn().mockResolvedValue(undefined), + updateSavedObjectsSpaces: jest.fn().mockResolvedValue(undefined), resolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(undefined), getShareSavedObjectPermissions: jest.fn().mockResolvedValue(undefined), redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined), diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts index 1cae128299197..a7201def5ed40 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -9,7 +9,10 @@ import type { Observable } from 'rxjs'; import { BehaviorSubject } from 'rxjs'; import { skipWhile } from 'rxjs/operators'; -import type { HttpSetup } from 'src/core/public'; +import type { + HttpSetup, + SavedObjectsCollectMultiNamespaceReferencesResponse, +} from 'src/core/public'; import type { Space } from 'src/plugins/spaces_oss/common'; import type { GetAllSpacesOptions, GetSpaceResult } from '../../common'; @@ -136,15 +139,21 @@ export class SpacesManager { }); } - public async shareSavedObjectAdd(object: SavedObjectTarget, spaces: string[]): Promise { - return this.http.post(`/api/spaces/_share_saved_object_add`, { - body: JSON.stringify({ object, spaces }), + public async getShareableReferences( + objects: SavedObjectTarget[] + ): Promise { + return this.http.post(`/api/spaces/_get_shareable_references`, { + body: JSON.stringify({ objects }), }); } - public async shareSavedObjectRemove(object: SavedObjectTarget, spaces: string[]): Promise { - return this.http.post(`/api/spaces/_share_saved_object_remove`, { - body: JSON.stringify({ object, spaces }), + public async updateSavedObjectsSpaces( + objects: SavedObjectTarget[], + spacesToAdd: string[], + spacesToRemove: string[] + ): Promise { + return this.http.post(`/api/spaces/_update_objects_spaces`, { + body: JSON.stringify({ objects, spacesToAdd, spacesToRemove }), }); } diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts index a8d8ed9b868c8..74ada21399f6e 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts @@ -9,7 +9,6 @@ import { Readable } from 'stream'; import type { SavedObjectsExportByObjectOptions, - SavedObjectsImportOptions, SavedObjectsImportResponse, SavedObjectsImportSuccess, } from 'src/core/server'; @@ -26,12 +25,9 @@ import { copySavedObjectsToSpacesFactory } from './copy_to_spaces'; interface SetupOpts { objects: Array<{ type: string; id: string; attributes: Record }>; exportByObjectsImpl?: (opts: SavedObjectsExportByObjectOptions) => Promise; - importSavedObjectsFromStreamImpl?: ( - opts: SavedObjectsImportOptions - ) => Promise; } -const expectStreamToContainObjects = async ( +const expectStreamToEqualObjects = async ( stream: Readable, expectedObjects: SetupOpts['objects'] ) => { @@ -50,10 +46,18 @@ const expectStreamToContainObjects = async ( }; describe('copySavedObjectsToSpaces', () => { + const FAILURE_SPACE = 'failure-space'; const mockExportResults = [ - { type: 'dashboard', id: 'my-dashboard', attributes: {} }, - { type: 'visualization', id: 'my-viz', attributes: {} }, - { type: 'index-pattern', id: 'my-index-pattern', attributes: {} }, + // For this test case, these three objects can be shared to multiple spaces + { type: 'dashboard', id: 'my-dashboard', namespaces: ['source'], attributes: {} }, + { type: 'visualization', id: 'my-viz', namespaces: ['source', 'destination1'], attributes: {} }, + { + type: 'index-pattern', + id: 'my-index-pattern', + namespaces: ['source', 'destination1', 'destination2'], + attributes: {}, + }, + // This object is namespace-agnostic and cannot be copied to another space { type: 'globaltype', id: 'my-globaltype', attributes: {} }, ]; @@ -73,7 +77,7 @@ describe('copySavedObjectsToSpaces', () => { // don't need to include all types, just need a positive case (agnostic) and a negative case (non-agnostic) { name: 'dashboard', - namespaceType: 'single', + namespaceType: 'multiple', hidden: false, mappings: { properties: {} }, }, @@ -105,21 +109,45 @@ describe('copySavedObjectsToSpaces', () => { }); savedObjectsImporter.import.mockImplementation(async (opts) => { - const defaultImpl = async () => { - // namespace-agnostic types should be filtered out before import - const filteredObjects = setupOpts.objects.filter(({ type }) => type !== 'globaltype'); - await expectStreamToContainObjects(opts.readStream, filteredObjects); - const response: SavedObjectsImportResponse = { - success: true, - successCount: filteredObjects.length, - successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], - warnings: [], - }; - - return Promise.resolve(response); + if (opts.namespace === FAILURE_SPACE) { + throw new Error(`Some error occurred!`); + } + + // expectedObjects will never include globaltype, and each object will have its namespaces field omitted + let expectedObjects = [ + { type: 'dashboard', id: 'my-dashboard', attributes: {} }, + { type: 'visualization', id: 'my-viz', attributes: {} }, + { type: 'index-pattern', id: 'my-index-pattern', attributes: {} }, + ]; + + if (!opts.createNewCopies) { + // if we are *not* creating new copies of objects, then we check destination spaces so we don't try to copy an object to a space where it already exists + switch (opts.namespace) { + case 'destination1': + expectedObjects = [ + { type: 'dashboard', id: 'my-dashboard', attributes: {} }, + // the visualization and index-pattern are not imported into destination1, they already exist there + ]; + break; + case 'destination2': + expectedObjects = [ + { type: 'dashboard', id: 'my-dashboard', attributes: {} }, + { type: 'visualization', id: 'my-viz', attributes: {} }, + // the index-pattern is not imported into destination2, it already exists there + ]; + break; + } + } + + await expectStreamToEqualObjects(opts.readStream, expectedObjects); + const response: SavedObjectsImportResponse = { + success: true, + successCount: expectedObjects.length, + successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], + warnings: [], }; - return setupOpts.importSavedObjectsFromStreamImpl?.(opts) ?? defaultImpl(); + return Promise.resolve(response); }); return { @@ -154,7 +182,7 @@ describe('copySavedObjectsToSpaces', () => { "destination1": Object { "errors": undefined, "success": true, - "successCount": 3, + "successCount": 1, "successResults": Array [ "Some success(es) occurred!", ], @@ -162,7 +190,7 @@ describe('copySavedObjectsToSpaces', () => { "destination2": Object { "errors": undefined, "success": true, - "successCount": 3, + "successCount": 2, "successResults": Array [ "Some success(es) occurred!", ], @@ -173,6 +201,7 @@ describe('copySavedObjectsToSpaces', () => { expect(savedObjectsExporter.exportByObjects).toHaveBeenCalledWith({ request: expect.any(Object), excludeExportDetails: true, + includeNamespaces: true, includeReferencesDeep: true, namespace, objects, @@ -193,23 +222,74 @@ describe('copySavedObjectsToSpaces', () => { }); }); + it('does not skip copying objects to spaces where they already exist if createNewCopies is enabled', async () => { + const { savedObjects, savedObjectsExporter, savedObjectsImporter } = setup({ + objects: mockExportResults.map(({ namespaces, ...remainingAttrs }) => ({ + ...remainingAttrs, // the objects are exported without the namespaces array + })), + }); + + const request = httpServerMock.createKibanaRequest(); + + const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory(savedObjects, request); + + const namespace = 'sourceSpace'; + const objects = [{ type: 'dashboard', id: 'my-dashboard' }]; + const result = await copySavedObjectsToSpaces(namespace, ['destination1', 'destination2'], { + includeReferences: true, + overwrite: false, + objects, + createNewCopies: true, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "destination1": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + "destination2": Object { + "errors": undefined, + "success": true, + "successCount": 3, + "successResults": Array [ + "Some success(es) occurred!", + ], + }, + } + `); + + expect(savedObjectsExporter.exportByObjects).toHaveBeenCalledWith({ + request: expect.any(Object), + excludeExportDetails: true, + includeNamespaces: false, + includeReferencesDeep: true, + namespace, + objects, + }); + + const importOptions = { + createNewCopies: true, + overwrite: false, + readStream: expect.any(Readable), + }; + expect(savedObjectsImporter.import).toHaveBeenNthCalledWith(1, { + ...importOptions, + namespace: 'destination1', + }); + expect(savedObjectsImporter.import).toHaveBeenNthCalledWith(2, { + ...importOptions, + namespace: 'destination2', + }); + }); + it(`doesn't stop copy if some spaces fail`, async () => { const { savedObjects } = setup({ objects: mockExportResults, - importSavedObjectsFromStreamImpl: async (opts) => { - if (opts.namespace === 'failure-space') { - throw new Error(`Some error occurred!`); - } - // namespace-agnostic types should be filtered out before import - const filteredObjects = mockExportResults.filter(({ type }) => type !== 'globaltype'); - await expectStreamToContainObjects(opts.readStream, filteredObjects); - return Promise.resolve({ - success: true, - successCount: filteredObjects.length, - successResults: [('Some success(es) occurred!' as unknown) as SavedObjectsImportSuccess], - warnings: [], - }); - }, }); const request = httpServerMock.createKibanaRequest(); @@ -218,7 +298,7 @@ describe('copySavedObjectsToSpaces', () => { const result = await copySavedObjectsToSpaces( 'sourceSpace', - ['failure-space', 'non-existent-space', 'marketing'], + [FAILURE_SPACE, 'non-existent-space', 'marketing'], { includeReferences: true, overwrite: true, @@ -226,6 +306,7 @@ describe('copySavedObjectsToSpaces', () => { createNewCopies: false, } ); + // See savedObjectsImporter.import mock implementation above; FAILURE_SPACE is a special case that will throw an error expect(result).toMatchInlineSnapshot(` Object { diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts index 29dac92e5fc6d..ed09c4d39d137 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts @@ -9,6 +9,7 @@ import type { Readable } from 'stream'; import type { CoreStart, KibanaRequest, SavedObject } from 'src/core/server'; +import { ALL_SPACES_ID } from '../../../common/constants'; import { spaceIdToNamespace } from '../utils/namespace'; import { createEmptyFailureResponse } from './lib/create_empty_failure_response'; import { getIneligibleTypes } from './lib/get_ineligible_types'; @@ -27,14 +28,12 @@ export function copySavedObjectsToSpacesFactory( const savedObjectsExporter = createExporter(savedObjectsClient); const savedObjectsImporter = createImporter(savedObjectsClient); - const exportRequestedObjects = async ( - sourceSpaceId: string, - options: Pick - ) => { + const exportRequestedObjects = async (sourceSpaceId: string, options: CopyOptions) => { const objectStream = await savedObjectsExporter.exportByObjects({ request, namespace: spaceIdToNamespace(sourceSpaceId), includeReferencesDeep: options.includeReferences, + includeNamespaces: !options.createNewCopies, // if we are not creating new copies, then include namespaces; this will ensure we can check for objects that already exist in the destination space below excludeExportDetails: true, objects: options.objects, }); @@ -76,13 +75,23 @@ export function copySavedObjectsToSpacesFactory( const exportedSavedObjects = await exportRequestedObjects(sourceSpaceId, options); const ineligibleTypes = getIneligibleTypes(getTypeRegistry()); const filteredObjects = exportedSavedObjects.filter( - ({ type }) => !ineligibleTypes.includes(type) + ({ type, namespaces }) => + // Don't attempt to copy ineligible types or objects that already exist in all spaces + !ineligibleTypes.includes(type) && !namespaces?.includes(ALL_SPACES_ID) ); for (const spaceId of destinationSpaceIds) { + const objectsToImport: SavedObject[] = []; + for (const { namespaces, ...object } of filteredObjects) { + if (!namespaces?.includes(spaceId)) { + // We check to ensure that each object doesn't already exist in the destination. If we don't do this, the consumer will see a + // conflict and have the option to skip or overwrite the object, both of which are effectively a no-op. + objectsToImport.push(object); + } + } response[spaceId] = await importObjectsToSpace( spaceId, - createReadableStreamFromArray(filteredObjects), + createReadableStreamFromArray(objectsToImport), options ); } diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts index 1ce030ef05d12..72a3921618ddc 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts @@ -91,6 +91,10 @@ export function resolveCopySavedObjectsToSpacesConflictsFactory( const retries = entryRetries.map((retry) => ({ ...retry, replaceReferences: [] })); + // We do *not* include a check to ensure that each object doesn't already exist in the destination. Since we already do this in + // copySavedObjectsToSpaces, it is much less likely to occur while resolving copy errors, and as such we've omitted the same check + // here to reduce complexity and test cases. + response[spaceId] = await resolveConflictsForSpace( spaceId, createReadableStreamFromArray(filteredObjects), diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.test.ts new file mode 100644 index 0000000000000..1100f767c33b8 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.test.ts @@ -0,0 +1,144 @@ +/* + * 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 * as Rx from 'rxjs'; + +import type { RouteValidatorConfig } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; +import { + coreMock, + httpServerMock, + httpServiceMock, + loggingSystemMock, +} from 'src/core/server/mocks'; + +import { spacesConfig } from '../../../lib/__fixtures__'; +import { SpacesClientService } from '../../../spaces_client'; +import { SpacesService } from '../../../spaces_service'; +import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; +import { + createMockSavedObjectsRepository, + createMockSavedObjectsService, + createSpaces, + mockRouteContext, + mockRouteContextWithInvalidLicense, +} from '../__fixtures__'; +import { initGetShareableReferencesApi } from './get_shareable_references'; + +describe('get shareable references', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map((s) => ({ id: s.id, ...s.attributes })); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter(); + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + const log = loggingSystemMock.create().get('spaces'); + const coreStart = coreMock.createStart(); + const { savedObjects, savedObjectsClient } = createMockSavedObjectsService(spaces); + coreStart.savedObjects = savedObjects; + + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, + }); + + const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); + + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, + }); + initGetShareableReferencesApi({ + externalRouter: router, + getStartServices: async () => [coreStart, {}, {}], + log, + getSpacesService: () => spacesServiceStart, + usageStatsServicePromise, + }); + + const [[getShareableReferences, getShareableReferencesRouteHandler]] = router.post.mock.calls; + + return { + coreStart, + savedObjectsClient, + getShareableReferences: { + routeValidation: getShareableReferences.validate as RouteValidatorConfig<{}, {}, {}>, + routeHandler: getShareableReferencesRouteHandler, + }, + savedObjectsRepositoryMock, + }; + }; + + describe('POST /api/spaces/_get_shareable_references', () => { + it(`returns http/403 when the license is invalid`, async () => { + const { getShareableReferences } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + }); + + const response = await getShareableReferences.routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it('passes arguments to the saved objects client and returns the result', async () => { + const { getShareableReferences, savedObjectsClient } = await setup(); + const reqObj1 = { type: 'a', id: 'id-1' }; + const reqObjects = [reqObj1]; + const payload = { objects: reqObjects }; + const collectedObjects = [ + // the return value of collectMultiNamespaceReferences includes the 1 requested object, along with the 2 references + { ...reqObj1, spaces: ['space-1'], inboundReferences: [] }, + { + type: 'b', + id: 'id-4', + spaces: ['space-1', '?', '?'], + inboundReferences: [{ ...reqObj1, name: 'ref-a:1' }], + }, + { + type: 'c', + id: 'id-5', + spaces: ['space-1', 'space-2'], + inboundReferences: [{ ...reqObj1, name: 'ref-a:1' }], + }, + ]; + savedObjectsClient.collectMultiNamespaceReferences.mockResolvedValue({ + objects: collectedObjects, + }); + + const request = httpServerMock.createKibanaRequest({ body: payload, method: 'post' }); + const response = await getShareableReferences.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual({ objects: collectedObjects }); + expect(savedObjectsClient.collectMultiNamespaceReferences).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.collectMultiNamespaceReferences).toHaveBeenCalledWith(reqObjects, { + purpose: 'updateObjectsSpaces', + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.ts b/x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.ts new file mode 100644 index 0000000000000..a7afd38dcecb0 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/get_shareable_references.ts @@ -0,0 +1,42 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { wrapError } from '../../../lib/errors'; +import { createLicensedRouteHandler } from '../../lib'; +import type { ExternalRouteDeps } from './'; + +export function initGetShareableReferencesApi(deps: ExternalRouteDeps) { + const { externalRouter, getStartServices } = deps; + + externalRouter.post( + { + path: '/api/spaces/_get_shareable_references', + validate: { + body: schema.object({ + objects: schema.arrayOf(schema.object({ type: schema.string(), id: schema.string() })), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const [startServices] = await getStartServices(); + const scopedClient = startServices.savedObjects.getScopedClient(request); + + const { objects } = request.body; + + try { + const collectedObjects = await scopedClient.collectMultiNamespaceReferences(objects, { + purpose: 'updateObjectsSpaces', + }); + return response.ok({ body: collectedObjects }); + } catch (error) { + return response.customError(wrapError(error)); + } + }) + ); +} diff --git a/x-pack/plugins/spaces/server/routes/api/external/index.ts b/x-pack/plugins/spaces/server/routes/api/external/index.ts index 3e2a523d767ea..9cebd8d0f9352 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts @@ -14,9 +14,10 @@ import { initCopyToSpacesApi } from './copy_to_space'; import { initDeleteSpacesApi } from './delete'; import { initGetSpaceApi } from './get'; import { initGetAllSpacesApi } from './get_all'; +import { initGetShareableReferencesApi } from './get_shareable_references'; import { initPostSpacesApi } from './post'; import { initPutSpacesApi } from './put'; -import { initShareToSpacesApi } from './share_to_space'; +import { initUpdateObjectsSpacesApi } from './update_objects_spaces'; export interface ExternalRouteDeps { externalRouter: SpacesRouter; @@ -33,5 +34,6 @@ export function initExternalSpacesApi(deps: ExternalRouteDeps) { initPostSpacesApi(deps); initPutSpacesApi(deps); initCopyToSpacesApi(deps); - initShareToSpacesApi(deps); + initUpdateObjectsSpacesApi(deps); + initGetShareableReferencesApi(deps); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts deleted file mode 100644 index cae6fd152d8ff..0000000000000 --- a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as Rx from 'rxjs'; - -import type { ObjectType } from '@kbn/config-schema'; -import type { RouteValidatorConfig } from 'src/core/server'; -import { kibanaResponseFactory } from 'src/core/server'; -import { - coreMock, - httpServerMock, - httpServiceMock, - loggingSystemMock, -} from 'src/core/server/mocks'; - -import { spacesConfig } from '../../../lib/__fixtures__'; -import { SpacesClientService } from '../../../spaces_client'; -import { SpacesService } from '../../../spaces_service'; -import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; -import { - createMockSavedObjectsRepository, - createMockSavedObjectsService, - createSpaces, - mockRouteContext, - mockRouteContextWithInvalidLicense, -} from '../__fixtures__'; -import { initShareToSpacesApi } from './share_to_space'; - -describe('share to space', () => { - const spacesSavedObjects = createSpaces(); - const spaces = spacesSavedObjects.map((s) => ({ id: s.id, ...s.attributes })); - - const setup = async () => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); - const log = loggingSystemMock.create().get('spaces'); - const coreStart = coreMock.createStart(); - const { savedObjects, savedObjectsClient } = createMockSavedObjectsService(spaces); - coreStart.savedObjects = savedObjects; - - const clientService = new SpacesClientService(jest.fn()); - clientService - .setup({ config$: Rx.of(spacesConfig) }) - .setClientRepositoryFactory(() => savedObjectsRepositoryMock); - - const service = new SpacesService(); - service.setup({ - basePath: httpService.basePath, - }); - - const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); - - const clientServiceStart = clientService.start(coreStart); - - const spacesServiceStart = service.start({ - basePath: coreStart.http.basePath, - spacesClientService: clientServiceStart, - }); - initShareToSpacesApi({ - externalRouter: router, - getStartServices: async () => [coreStart, {}, {}], - log, - getSpacesService: () => spacesServiceStart, - usageStatsServicePromise, - }); - - const [ - [shareAdd, ctsRouteHandler], - [shareRemove, resolveRouteHandler], - ] = router.post.mock.calls; - - return { - coreStart, - savedObjectsClient, - shareAdd: { - routeValidation: shareAdd.validate as RouteValidatorConfig<{}, {}, {}>, - routeHandler: ctsRouteHandler, - }, - shareRemove: { - routeValidation: shareRemove.validate as RouteValidatorConfig<{}, {}, {}>, - routeHandler: resolveRouteHandler, - }, - savedObjectsRepositoryMock, - }; - }; - - describe('POST /api/spaces/_share_saved_object_add', () => { - const object = { id: 'foo', type: 'bar' }; - - it(`returns http/403 when the license is invalid`, async () => { - const { shareAdd } = await setup(); - - const request = httpServerMock.createKibanaRequest({ method: 'post' }); - const response = await shareAdd.routeHandler( - mockRouteContextWithInvalidLicense, - request, - kibanaResponseFactory - ); - - expect(response.status).toEqual(403); - expect(response.payload).toEqual({ - message: 'License is invalid for spaces', - }); - }); - - it(`requires at least 1 space ID`, async () => { - const { shareAdd } = await setup(); - const payload = { spaces: [], object }; - - expect(() => - (shareAdd.routeValidation.body as ObjectType).validate(payload) - ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: must specify one or more space ids"`); - }); - - it(`requires space IDs to be unique`, async () => { - const { shareAdd } = await setup(); - const payload = { spaces: ['a-space', 'a-space'], object }; - - expect(() => - (shareAdd.routeValidation.body as ObjectType).validate(payload) - ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: duplicate space ids are not allowed"`); - }); - - it(`requires well-formed space IDS`, async () => { - const { shareAdd } = await setup(); - const payload = { spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], object }; - - expect(() => - (shareAdd.routeValidation.body as ObjectType).validate(payload) - ).toThrowErrorMatchingInlineSnapshot( - `"[spaces.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed, OR \\"*\\""` - ); - }); - - it(`allows all spaces ("*")`, async () => { - const { shareAdd } = await setup(); - const payload = { spaces: ['*'], object }; - - expect(() => - (shareAdd.routeValidation.body as ObjectType).validate(payload) - ).not.toThrowError(); - }); - - it('adds the object to the specified space(s)', async () => { - const { shareAdd, savedObjectsClient } = await setup(); - const payload = { spaces: ['a-space', 'b-space'], object }; - - const request = httpServerMock.createKibanaRequest({ body: payload, method: 'post' }); - const response = await shareAdd.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status } = response; - expect(status).toEqual(204); - expect(savedObjectsClient.addToNamespaces).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.addToNamespaces).toHaveBeenCalledWith( - payload.object.type, - payload.object.id, - payload.spaces - ); - }); - }); - - describe('POST /api/spaces/_share_saved_object_remove', () => { - const object = { id: 'foo', type: 'bar' }; - - it(`returns http/403 when the license is invalid`, async () => { - const { shareRemove } = await setup(); - - const request = httpServerMock.createKibanaRequest({ - method: 'post', - }); - - const response = await shareRemove.routeHandler( - mockRouteContextWithInvalidLicense, - request, - kibanaResponseFactory - ); - - expect(response.status).toEqual(403); - expect(response.payload).toEqual({ - message: 'License is invalid for spaces', - }); - }); - - it(`requires at least 1 space ID`, async () => { - const { shareRemove } = await setup(); - const payload = { spaces: [], object }; - - expect(() => - (shareRemove.routeValidation.body as ObjectType).validate(payload) - ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: must specify one or more space ids"`); - }); - - it(`requires space IDs to be unique`, async () => { - const { shareRemove } = await setup(); - const payload = { spaces: ['a-space', 'a-space'], object }; - - expect(() => - (shareRemove.routeValidation.body as ObjectType).validate(payload) - ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: duplicate space ids are not allowed"`); - }); - - it(`requires well-formed space IDS`, async () => { - const { shareRemove } = await setup(); - const payload = { spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], object }; - - expect(() => - (shareRemove.routeValidation.body as ObjectType).validate(payload) - ).toThrowErrorMatchingInlineSnapshot( - `"[spaces.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed, OR \\"*\\""` - ); - }); - - it(`allows all spaces ("*")`, async () => { - const { shareRemove } = await setup(); - const payload = { spaces: ['*'], object }; - - expect(() => - (shareRemove.routeValidation.body as ObjectType).validate(payload) - ).not.toThrowError(); - }); - - it('removes the object from the specified space(s)', async () => { - const { shareRemove, savedObjectsClient } = await setup(); - const payload = { spaces: ['a-space', 'b-space'], object }; - - const request = httpServerMock.createKibanaRequest({ body: payload, method: 'post' }); - const response = await shareRemove.routeHandler( - mockRouteContext, - request, - kibanaResponseFactory - ); - - const { status } = response; - expect(status).toEqual(204); - expect(savedObjectsClient.deleteFromNamespaces).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.deleteFromNamespaces).toHaveBeenCalledWith( - payload.object.type, - payload.object.id, - payload.spaces - ); - }); - }); -}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts deleted file mode 100644 index 1c6f254354cb2..0000000000000 --- a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { schema } from '@kbn/config-schema'; - -import { ALL_SPACES_ID } from '../../../../common/constants'; -import { wrapError } from '../../../lib/errors'; -import { SPACE_ID_REGEX } from '../../../lib/space_schema'; -import { createLicensedRouteHandler } from '../../lib'; -import type { ExternalRouteDeps } from './'; - -const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); -export function initShareToSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, getStartServices } = deps; - - const shareSchema = schema.object({ - spaces: schema.arrayOf( - schema.string({ - validate: (value) => { - if (value !== ALL_SPACES_ID && !SPACE_ID_REGEX.test(value)) { - return `lower case, a-z, 0-9, "_", and "-" are allowed, OR "*"`; - } - }, - }), - { - validate: (spaceIds) => { - if (!spaceIds.length) { - return 'must specify one or more space ids'; - } else if (uniq(spaceIds).length !== spaceIds.length) { - return 'duplicate space ids are not allowed'; - } - }, - } - ), - object: schema.object({ type: schema.string(), id: schema.string() }), - }); - - externalRouter.post( - { path: '/api/spaces/_share_saved_object_add', validate: { body: shareSchema } }, - createLicensedRouteHandler(async (_context, request, response) => { - const [startServices] = await getStartServices(); - const scopedClient = startServices.savedObjects.getScopedClient(request); - - const spaces = request.body.spaces; - const { type, id } = request.body.object; - - try { - await scopedClient.addToNamespaces(type, id, spaces); - } catch (error) { - return response.customError(wrapError(error)); - } - return response.noContent(); - }) - ); - - externalRouter.post( - { path: '/api/spaces/_share_saved_object_remove', validate: { body: shareSchema } }, - createLicensedRouteHandler(async (_context, request, response) => { - const [startServices] = await getStartServices(); - const scopedClient = startServices.savedObjects.getScopedClient(request); - - const spaces = request.body.spaces; - const { type, id } = request.body.object; - - try { - await scopedClient.deleteFromNamespaces(type, id, spaces); - } catch (error) { - return response.customError(wrapError(error)); - } - return response.noContent(); - }) - ); -} diff --git a/x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.test.ts b/x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.test.ts new file mode 100644 index 0000000000000..06968c3bcb50e --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.test.ts @@ -0,0 +1,176 @@ +/* + * 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 * as Rx from 'rxjs'; + +import type { ObjectType } from '@kbn/config-schema'; +import type { RouteValidatorConfig } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; +import { + coreMock, + httpServerMock, + httpServiceMock, + loggingSystemMock, +} from 'src/core/server/mocks'; + +import { spacesConfig } from '../../../lib/__fixtures__'; +import { SpacesClientService } from '../../../spaces_client'; +import { SpacesService } from '../../../spaces_service'; +import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; +import { + createMockSavedObjectsRepository, + createMockSavedObjectsService, + createSpaces, + mockRouteContext, + mockRouteContextWithInvalidLicense, +} from '../__fixtures__'; +import { initUpdateObjectsSpacesApi } from './update_objects_spaces'; + +describe('update_objects_spaces', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map((s) => ({ id: s.id, ...s.attributes })); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter(); + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + const log = loggingSystemMock.create().get('spaces'); + const coreStart = coreMock.createStart(); + const { savedObjects, savedObjectsClient } = createMockSavedObjectsService(spaces); + coreStart.savedObjects = savedObjects; + + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, + }); + + const usageStatsServicePromise = Promise.resolve(usageStatsServiceMock.createSetupContract()); + + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, + }); + initUpdateObjectsSpacesApi({ + externalRouter: router, + getStartServices: async () => [coreStart, {}, {}], + log, + getSpacesService: () => spacesServiceStart, + usageStatsServicePromise, + }); + + const [[updateObjectsSpaces, updateObjectsSpacesRouteHandler]] = router.post.mock.calls; + + return { + coreStart, + savedObjectsClient, + updateObjectsSpaces: { + routeValidation: updateObjectsSpaces.validate as RouteValidatorConfig<{}, {}, {}>, + routeHandler: updateObjectsSpacesRouteHandler, + }, + savedObjectsRepositoryMock, + }; + }; + + describe('POST /api/spaces/_update_objects_spaces', () => { + const objects = [{ id: 'foo', type: 'bar' }]; + + it(`returns http/403 when the license is invalid`, async () => { + const { updateObjectsSpaces } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + }); + + const response = await updateObjectsSpaces.routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it(`requires space IDs to be unique`, async () => { + const { updateObjectsSpaces } = await setup(); + const targetSpaces = ['a-space', 'a-space']; + const payload1 = { objects, spacesToAdd: targetSpaces, spacesToRemove: [] }; + const payload2 = { objects, spacesToAdd: [], spacesToRemove: targetSpaces }; + + expect(() => + (updateObjectsSpaces.routeValidation.body as ObjectType).validate(payload1) + ).toThrowErrorMatchingInlineSnapshot(`"[spacesToAdd]: duplicate space ids are not allowed"`); + expect(() => + (updateObjectsSpaces.routeValidation.body as ObjectType).validate(payload2) + ).toThrowErrorMatchingInlineSnapshot( + `"[spacesToRemove]: duplicate space ids are not allowed"` + ); + }); + + it(`requires well-formed space IDS`, async () => { + const { updateObjectsSpaces } = await setup(); + const targetSpaces = ['a-space', 'a-space-invalid-!@#$%^&*()']; + const payload1 = { objects, spacesToAdd: targetSpaces, spacesToRemove: [] }; + const payload2 = { objects, spacesToAdd: [], spacesToRemove: targetSpaces }; + + expect(() => + (updateObjectsSpaces.routeValidation.body as ObjectType).validate(payload1) + ).toThrowErrorMatchingInlineSnapshot( + `"[spacesToAdd.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed, OR \\"*\\""` + ); + expect(() => + (updateObjectsSpaces.routeValidation.body as ObjectType).validate(payload2) + ).toThrowErrorMatchingInlineSnapshot( + `"[spacesToRemove.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed, OR \\"*\\""` + ); + }); + + it(`allows all spaces ("*")`, async () => { + const { updateObjectsSpaces } = await setup(); + const targetSpaces = ['*']; + const payload1 = { objects, spacesToAdd: targetSpaces, spacesToRemove: [] }; + const payload2 = { objects, spacesToAdd: [], spacesToRemove: targetSpaces }; + + expect(() => + (updateObjectsSpaces.routeValidation.body as ObjectType).validate(payload1) + ).not.toThrowError(); + expect(() => + (updateObjectsSpaces.routeValidation.body as ObjectType).validate(payload2) + ).not.toThrowError(); + }); + + it('passes arguments to the saved objects client and returns the result', async () => { + const { updateObjectsSpaces, savedObjectsClient } = await setup(); + const payload = { objects, spacesToAdd: ['a-space'], spacesToRemove: ['b-space'] }; + + const request = httpServerMock.createKibanaRequest({ body: payload, method: 'post' }); + const response = await updateObjectsSpaces.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status } = response; + expect(status).toEqual(200); + expect(savedObjectsClient.updateObjectsSpaces).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.updateObjectsSpaces).toHaveBeenCalledWith( + objects, + payload.spacesToAdd, + payload.spacesToRemove + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.ts new file mode 100644 index 0000000000000..4486d4b3ade09 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/update_objects_spaces.ts @@ -0,0 +1,70 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { ALL_SPACES_ID } from '../../../../common/constants'; +import { wrapError } from '../../../lib/errors'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; +import { createLicensedRouteHandler } from '../../lib'; +import type { ExternalRouteDeps } from './'; + +export function initUpdateObjectsSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, getStartServices } = deps; + + const spacesSchema = schema.arrayOf( + schema.string({ + validate: (value) => { + if (value !== ALL_SPACES_ID && !SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed, OR "*"`; + } + }, + }), + { + validate: (spaceIds) => { + if (uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ); + + externalRouter.post( + { + path: '/api/spaces/_update_objects_spaces', + validate: { + body: schema.object({ + objects: schema.arrayOf(schema.object({ type: schema.string(), id: schema.string() })), + spacesToAdd: spacesSchema, + spacesToRemove: spacesSchema, + }), + }, + }, + createLicensedRouteHandler(async (_context, request, response) => { + const [startServices] = await getStartServices(); + const scopedClient = startServices.savedObjects.getScopedClient(request); + + const { objects, spacesToAdd, spacesToRemove } = request.body; + + try { + const updateObjectsSpacesResponse = await scopedClient.updateObjectsSpaces( + objects, + spacesToAdd, + spacesToRemove + ); + return response.ok({ body: updateObjectsSpacesResponse }); + } catch (error) { + return response.customError(wrapError(error)); + } + }) + ); +} + +/** Returns all unique elements of an array. */ +function uniq(arr: T[]): T[] { + return Array.from(new Set(arr)); +} 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 cbb71d4bbcf81..56bfe71b581ed 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 @@ -503,66 +503,6 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); }); - describe('#addToNamespaces', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - // @ts-expect-error - client.addToNamespaces(null, null, null, { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { namespaces: ['foo', 'bar'] }; - baseClient.addToNamespaces.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const type = Symbol(); - const id = Symbol(); - const namespaces = Symbol(); - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.addToNamespaces(type, id, namespaces, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.addToNamespaces).toHaveBeenCalledWith(type, id, namespaces, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - - describe('#deleteFromNamespaces', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = createSpacesSavedObjectsClient(); - - await expect( - // @ts-expect-error - client.deleteFromNamespaces(null, null, null, { namespace: 'bar' }) - ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); - }); - - test(`supplements options with the current namespace`, async () => { - const { client, baseClient } = createSpacesSavedObjectsClient(); - const expectedReturnValue = { namespaces: ['foo', 'bar'] }; - baseClient.deleteFromNamespaces.mockReturnValue(Promise.resolve(expectedReturnValue)); - - const type = Symbol(); - const id = Symbol(); - const namespaces = Symbol(); - const options = Object.freeze({ foo: 'bar' }); - // @ts-expect-error - const actualReturnValue = await client.deleteFromNamespaces(type, id, namespaces, options); - - expect(actualReturnValue).toBe(expectedReturnValue); - expect(baseClient.deleteFromNamespaces).toHaveBeenCalledWith(type, id, namespaces, { - foo: 'bar', - namespace: currentSpace.expectedNamespace, - }); - }); - }); - describe('#removeReferencesTo', () => { test(`throws error if options.namespace is specified`, async () => { const { client } = createSpacesSavedObjectsClient(); @@ -681,5 +621,70 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; expect(baseClient.createPointInTimeFinder).toHaveBeenCalledWith(options, dependencies); }); }); + + describe('#collectMultiNamespaceReferences', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect( + client.collectMultiNamespaceReferences([], { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { objects: [] }; + baseClient.collectMultiNamespaceReferences.mockReturnValue( + Promise.resolve(expectedReturnValue) + ); + + const objects = [{ type: 'foo', id: 'bar' }]; + const options = Object.freeze({ foo: 'bar' }); + // @ts-expect-error + const actualReturnValue = await client.collectMultiNamespaceReferences(objects, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.collectMultiNamespaceReferences).toHaveBeenCalledWith(objects, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#updateObjectsSpaces', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = createSpacesSavedObjectsClient(); + + await expect(client.updateObjectsSpaces([], [], [], { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = createSpacesSavedObjectsClient(); + const expectedReturnValue = { objects: [] }; + baseClient.updateObjectsSpaces.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const objects = [{ type: 'foo', id: 'bar' }]; + const spacesToAdd = ['space-x']; + const spacesToRemove = ['space-y']; + const options = Object.freeze({ foo: 'bar' }); + const actualReturnValue = await client.updateObjectsSpaces( + objects, + spacesToAdd, + spacesToRemove, + // @ts-expect-error + options + ); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.updateObjectsSpaces).toHaveBeenCalledWith( + objects, + spacesToAdd, + spacesToRemove, + { foo: 'bar', namespace: currentSpace.expectedNamespace } + ); + }); + }); }); }); 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 4254615ac7d5f..e344aa8cecf07 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 @@ -9,7 +9,6 @@ import Boom from '@hapi/boom'; import type { ISavedObjectTypeRegistry, - SavedObjectsAddToNamespacesOptions, SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, @@ -17,13 +16,17 @@ import type { SavedObjectsCheckConflictsObject, SavedObjectsClientContract, SavedObjectsClosePointInTimeOptions, + SavedObjectsCollectMultiNamespaceReferencesObject, + SavedObjectsCollectMultiNamespaceReferencesOptions, + SavedObjectsCollectMultiNamespaceReferencesResponse, SavedObjectsCreateOptions, SavedObjectsCreatePointInTimeFinderDependencies, SavedObjectsCreatePointInTimeFinderOptions, - SavedObjectsDeleteFromNamespacesOptions, SavedObjectsFindOptions, SavedObjectsOpenPointInTimeOptions, SavedObjectsRemoveReferencesToOptions, + SavedObjectsUpdateObjectsSpacesObject, + SavedObjectsUpdateObjectsSpacesOptions, SavedObjectsUpdateOptions, } from 'src/core/server'; @@ -300,86 +303,80 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { } /** - * Adds namespaces to a SavedObject + * Updates an array of objects by id * - * @param type - * @param id - * @param namespaces - * @param options + * @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 addToNamespaces( - type: string, - id: string, - namespaces: string[], - options: SavedObjectsAddToNamespacesOptions = {} + public async bulkUpdate( + objects: Array> = [], + options: SavedObjectsBaseOptions = {} ) { throwErrorIfNamespaceSpecified(options); - - return await this.client.addToNamespaces(type, id, namespaces, { + return await this.client.bulkUpdate(objects, { ...options, namespace: spaceIdToNamespace(this.spaceId), }); } /** - * Removes namespaces from a SavedObject + * Remove outward references to given object. * * @param type * @param id - * @param namespaces * @param options */ - public async deleteFromNamespaces( + public async removeReferencesTo( type: string, id: string, - namespaces: string[], - options: SavedObjectsDeleteFromNamespacesOptions = {} + options: SavedObjectsRemoveReferencesToOptions = {} ) { throwErrorIfNamespaceSpecified(options); - - return await this.client.deleteFromNamespaces(type, id, namespaces, { + return await this.client.removeReferencesTo(type, id, { ...options, namespace: spaceIdToNamespace(this.spaceId), }); } /** - * Updates an array of objects by id + * Gets all references and transitive references of the listed objects. Ignores any object that is not a multi-namespace type. * - * @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: {} } - * ]) + * @param objects + * @param options */ - public async bulkUpdate( - objects: Array> = [], - options: SavedObjectsBaseOptions = {} - ) { + public async collectMultiNamespaceReferences( + objects: SavedObjectsCollectMultiNamespaceReferencesObject[], + options: SavedObjectsCollectMultiNamespaceReferencesOptions = {} + ): Promise { throwErrorIfNamespaceSpecified(options); - return await this.client.bulkUpdate(objects, { + return await this.client.collectMultiNamespaceReferences(objects, { ...options, namespace: spaceIdToNamespace(this.spaceId), }); } /** - * Remove outward references to given object. + * Updates one or more objects to add and/or remove them from specified spaces. * - * @param type - * @param id + * @param objects + * @param spacesToAdd + * @param spacesToRemove * @param options */ - public async removeReferencesTo( - type: string, - id: string, - options: SavedObjectsRemoveReferencesToOptions = {} + public async updateObjectsSpaces( + objects: SavedObjectsUpdateObjectsSpacesObject[], + spacesToAdd: string[], + spacesToRemove: string[], + options: SavedObjectsUpdateObjectsSpacesOptions = {} ) { throwErrorIfNamespaceSpecified(options); - return await this.client.removeReferencesTo(type, id, { + return await this.client.updateObjectsSpaces(objects, spacesToAdd, spacesToRemove, { ...options, namespace: spaceIdToNamespace(this.spaceId), }); @@ -434,7 +431,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { * @param {object} findOptions - {@link SavedObjectsCreatePointInTimeFinderOptions} * @param {object} [dependencies] - {@link SavedObjectsCreatePointInTimeFinderDependencies} */ - createPointInTimeFinder( + createPointInTimeFinder( findOptions: SavedObjectsCreatePointInTimeFinderOptions, dependencies?: SavedObjectsCreatePointInTimeFinderDependencies ) { @@ -443,7 +440,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { // is simply a helper that calls `find`, `openPointInTimeForType`, and // `closePointInTime` internally, so namespaces will already be handled // in those methods. - return this.client.createPointInTimeFinder(findOptions, { + return this.client.createPointInTimeFinder(findOptions, { client: this, // Include dependencies last so that subsequent SO client wrappers have their settings applied. ...dependencies, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 244b294baffe5..ae61f24201ce5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22821,8 +22821,6 @@ "xpack.spaces.shareToSpace.privilegeWarningTitle": "追加の権限が必要です", "xpack.spaces.shareToSpace.redirectLegacyUrlToast.text": "検索している{objectNoun}は新しい場所にあります。今後はこのURLを使用してください。", "xpack.spaces.shareToSpace.redirectLegacyUrlToast.title": "新しいURLに移動しました", - "xpack.spaces.shareToSpace.shareAddSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースに追加されました。", - "xpack.spaces.shareToSpace.shareAddSuccessTextSingular": "「{object}」は1つのスペースに追加されました。", "xpack.spaces.shareToSpace.shareErrorTitle": "{objectNoun}の更新エラー", "xpack.spaces.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount}個が非表示", "xpack.spaces.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount}個が選択済み", @@ -22833,8 +22831,6 @@ "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title": "すべてのスペース", "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "選択したスペースでのみ{objectNoun}を使用可能にします。", "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "スペースを選択", - "xpack.spaces.shareToSpace.shareRemoveSuccessTextPlural": "「{object}」は{spaceTargets}個のスペースから削除されました。", - "xpack.spaces.shareToSpace.shareRemoveSuccessTextSingular": "「{object}」は1つのスペースから削除されました。", "xpack.spaces.shareToSpace.shareSuccessTitle": "{objectNoun}を更新しました", "xpack.spaces.shareToSpace.shareToSpacesButton": "保存して閉じる", "xpack.spaces.shareToSpace.shareWarningBody": "変更は選択した各スペースに表示されます。変更を同期しない場合は、{makeACopyLink}。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8bdffce98d4ab..ecd6c0d68a94f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23182,8 +23182,6 @@ "xpack.spaces.shareToSpace.privilegeWarningTitle": "需要其他权限", "xpack.spaces.shareToSpace.redirectLegacyUrlToast.text": "您正在寻找的{objectNoun}具有新的位置。从现在开始使用此 URL。", "xpack.spaces.shareToSpace.redirectLegacyUrlToast.title": "我们已将您重定向到新 URL", - "xpack.spaces.shareToSpace.shareAddSuccessTextPlural": "“{object}”已添加到 {spaceTargets} 个工作区。", - "xpack.spaces.shareToSpace.shareAddSuccessTextSingular": "“{object}”已添加到 1 个工作区。", "xpack.spaces.shareToSpace.shareErrorTitle": "更新 {objectNoun} 时出错", "xpack.spaces.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount} 个已隐藏", "xpack.spaces.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount} 个已选择", @@ -23194,8 +23192,6 @@ "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title": "所有工作区", "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "仅使 {objectNoun} 在选定工作区中可用。", "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "选择工作区", - "xpack.spaces.shareToSpace.shareRemoveSuccessTextPlural": "“{object}”已从 {spaceTargets} 个工作区中移除。", - "xpack.spaces.shareToSpace.shareRemoveSuccessTextSingular": "“{object}”已从 1 个工作区中移除。", "xpack.spaces.shareToSpace.shareSuccessTitle": "已更新 {objectNoun}", "xpack.spaces.shareToSpace.shareToSpacesButton": "保存并关闭", "xpack.spaces.shareToSpace.shareWarningBody": "您的更改显示在您选择的每个工作区中。如果不想同步您的更改,{makeACopyLink}。", diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts b/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts index c7af01c60fa52..19d50474fcc73 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/can_delete_job.ts @@ -53,7 +53,7 @@ export default ({ getService }: FtrProviderContext) => { idStarSpace ); - await ml.api.asignJobToSpaces(adJobIdSpace12, 'anomaly-detector', [idSpace2], idSpace1); + await ml.api.updateJobSpaces(adJobIdSpace12, 'anomaly-detector', [idSpace2], [], idSpace1); await ml.api.assertJobSpaces(adJobIdSpace12, 'anomaly-detector', [idSpace1, idSpace2]); await ml.testResources.setKibanaTimeZoneToUTC(); diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/index.ts b/x-pack/test/api_integration/apis/ml/saved_objects/index.ts index a4e9458609b0c..99ef48b2337d5 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/index.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/index.ts @@ -10,11 +10,10 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('saved objects', function () { loadTestFile(require.resolve('./jobs_spaces')); - loadTestFile(require.resolve('./assign_job_to_space')); loadTestFile(require.resolve('./can_delete_job')); loadTestFile(require.resolve('./initialize')); loadTestFile(require.resolve('./status')); - loadTestFile(require.resolve('./remove_job_from_space')); loadTestFile(require.resolve('./sync')); + loadTestFile(require.resolve('./update_jobs_spaces')); }); } diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/remove_job_from_space.ts b/x-pack/test/api_integration/apis/ml/saved_objects/remove_job_from_space.ts deleted file mode 100644 index dec4523d39535..0000000000000 --- a/x-pack/test/api_integration/apis/ml/saved_objects/remove_job_from_space.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../ftr_provider_context'; -import { USER } from '../../../../functional/services/ml/security_common'; -import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; -import { JobType } from '../../../../../plugins/ml/common/types/saved_objects'; - -export default ({ getService }: FtrProviderContext) => { - const ml = getService('ml'); - const spacesService = getService('spaces'); - const supertest = getService('supertestWithoutAuth'); - - const adJobId = 'fq_single'; - const idSpace1 = 'space1'; - const idSpace2 = 'space2'; - - async function runRequest( - requestBody: { - jobType: JobType; - jobIds: string[]; - spaces: string[]; - }, - expectedStatusCode: number, - user: USER, - space?: string - ) { - const { body } = await supertest - .post(`${space ? `/s/${space}` : ''}/api/ml/saved_objects/remove_job_from_space`) - .auth(user, ml.securityCommon.getPasswordForUser(user)) - .set(COMMON_REQUEST_HEADERS) - .send(requestBody) - .expect(expectedStatusCode); - - return body; - } - - describe('POST saved_objects/remove_job_from_space', () => { - before(async () => { - await spacesService.create({ id: idSpace1, name: 'space_one', disabledFeatures: [] }); - await spacesService.create({ id: idSpace2, name: 'space_two', disabledFeatures: [] }); - - await ml.testResources.setKibanaTimeZoneToUTC(); - }); - - beforeEach(async () => { - await ml.api.createAnomalyDetectionJob( - ml.commonConfig.getADFqSingleMetricJobConfig(adJobId), - idSpace1 - ); - }); - - afterEach(async () => { - await ml.api.cleanMlIndices(); - await ml.testResources.cleanMLSavedObjects(); - }); - - after(async () => { - await spacesService.delete(idSpace1); - await spacesService.delete(idSpace2); - }); - - it('should remove job from same space', async () => { - await ml.api.assertJobSpaces(adJobId, 'anomaly-detector', [idSpace1]); - - const body = await runRequest( - { - jobType: 'anomaly-detector', - jobIds: [adJobId], - spaces: [idSpace1], - }, - 200, - USER.ML_POWERUSER_ALL_SPACES, - idSpace1 - ); - - expect(body).to.eql({ [adJobId]: { success: true } }); - await ml.api.assertJobSpaces(adJobId, 'anomaly-detector', []); - }); - - it('should not find job to remove from different space', async () => { - await ml.api.assertJobSpaces(adJobId, 'anomaly-detector', [idSpace1]); - - const body = await runRequest( - { - jobType: 'anomaly-detector', - jobIds: [adJobId], - spaces: [idSpace1], - }, - 200, - USER.ML_POWERUSER_ALL_SPACES, - idSpace2 - ); - - expect(body).to.eql({}); - await ml.api.assertJobSpaces(adJobId, 'anomaly-detector', [idSpace1]); - }); - }); -}; diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/assign_job_to_space.ts b/x-pack/test/api_integration/apis/ml/saved_objects/update_jobs_spaces.ts similarity index 85% rename from x-pack/test/api_integration/apis/ml/saved_objects/assign_job_to_space.ts rename to x-pack/test/api_integration/apis/ml/saved_objects/update_jobs_spaces.ts index 12bd89716c044..89233fe11dbc6 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/assign_job_to_space.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/update_jobs_spaces.ts @@ -27,13 +27,14 @@ export default ({ getService }: FtrProviderContext) => { requestBody: { jobType: JobType; jobIds: string[]; - spaces: string[]; + spacesToAdd: string[]; + spacesToRemove: string[]; }, expectedStatusCode: number, user: USER ) { const { body } = await supertest - .post(`/api/ml/saved_objects/assign_job_to_space`) + .post(`/api/ml/saved_objects/update_jobs_spaces`) .auth(user, ml.securityCommon.getPasswordForUser(user)) .set(COMMON_REQUEST_HEADERS) .send(requestBody) @@ -42,7 +43,7 @@ export default ({ getService }: FtrProviderContext) => { return body; } - describe('POST saved_objects/assign_job_to_space', () => { + describe('POST saved_objects/update_jobs_spaces', () => { before(async () => { await esArchiver.loadIfNeeded('ml/ihp_outlier'); await spacesService.create({ id: idSpace1, name: 'space_one', disabledFeatures: [] }); @@ -74,14 +75,15 @@ export default ({ getService }: FtrProviderContext) => { { jobType: 'anomaly-detector', jobIds: [adJobId], - spaces: [idSpace1], + spacesToAdd: [idSpace1], + spacesToRemove: [defaultSpaceId], }, 200, USER.ML_POWERUSER_SPACE1 ); expect(body).to.eql({ [adJobId]: { success: true } }); - await ml.api.assertJobSpaces(adJobId, 'anomaly-detector', [defaultSpaceId, idSpace1]); + await ml.api.assertJobSpaces(adJobId, 'anomaly-detector', [idSpace1]); }); it('should assign DFA job to space for user with access to that space', async () => { @@ -90,23 +92,25 @@ export default ({ getService }: FtrProviderContext) => { { jobType: 'data-frame-analytics', jobIds: [dfaJobId], - spaces: [idSpace1], + spacesToAdd: [idSpace1], + spacesToRemove: [defaultSpaceId], }, 200, USER.ML_POWERUSER_SPACE1 ); expect(body).to.eql({ [dfaJobId]: { success: true } }); - await ml.api.assertJobSpaces(dfaJobId, 'data-frame-analytics', [defaultSpaceId, idSpace1]); + await ml.api.assertJobSpaces(dfaJobId, 'data-frame-analytics', [idSpace1]); }); - it('should fail to assign AD job to space the user has no access to', async () => { + it('should fail to update AD job spaces for space the user has no access to', async () => { await ml.api.assertJobSpaces(adJobId, 'anomaly-detector', [defaultSpaceId]); const body = await runRequest( { jobType: 'anomaly-detector', jobIds: [adJobId], - spaces: [idSpace2], + spacesToAdd: [idSpace2], + spacesToRemove: [], }, 200, USER.ML_POWERUSER_SPACE1 @@ -116,13 +120,14 @@ export default ({ getService }: FtrProviderContext) => { await ml.api.assertJobSpaces(adJobId, 'anomaly-detector', [defaultSpaceId]); }); - it('should fail to assign DFA job to space the user has no access to', async () => { + it('should fail to update DFA job spaces for space the user has no access to', async () => { await ml.api.assertJobSpaces(dfaJobId, 'data-frame-analytics', [defaultSpaceId]); const body = await runRequest( { jobType: 'data-frame-analytics', jobIds: [dfaJobId], - spaces: [idSpace2], + spacesToAdd: [idSpace2], + spacesToRemove: [], }, 200, USER.ML_POWERUSER_SPACE1 diff --git a/x-pack/test/functional/services/ml/api.ts b/x-pack/test/functional/services/ml/api.ts index c0e3dedd8e191..d341a27455a3c 100644 --- a/x-pack/test/functional/services/ml/api.ts +++ b/x-pack/test/functional/services/ml/api.ts @@ -892,26 +892,17 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { await this.waitForAnalyticsState(dfaConfig.id, DATA_FRAME_TASK_STATE.STOPPED); }, - async asignJobToSpaces(jobId: string, jobType: JobType, spacesToAdd: string[], space?: string) { - const { body } = await kbnSupertest - .post(`${space ? `/s/${space}` : ''}/api/ml/saved_objects/assign_job_to_space`) - .set(COMMON_REQUEST_HEADERS) - .send({ jobType, jobIds: [jobId], spaces: spacesToAdd }) - .expect(200); - - expect(body).to.eql({ [jobId]: { success: true } }); - }, - - async removeJobFromSpaces( + async updateJobSpaces( jobId: string, jobType: JobType, + spacesToAdd: string[], spacesToRemove: string[], space?: string ) { const { body } = await kbnSupertest - .post(`${space ? `/s/${space}` : ''}/api/ml/saved_objects/remove_job_from_space`) + .post(`${space ? `/s/${space}` : ''}/api/ml/saved_objects/update_jobs_spaces`) .set(COMMON_REQUEST_HEADERS) - .send({ jobType, jobIds: [jobId], spaces: spacesToRemove }) + .send({ jobType, jobIds: [jobId], spacesToAdd, spacesToRemove }) .expect(200); expect(body).to.eql({ [jobId]: { success: true } }); diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 5fac012d5e8b9..d83c550c15ff6 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -544,6 +544,7 @@ "type": "legacy-url-alias", "updated_at": "2017-09-21T18:51:23.794Z", "legacy-url-alias": { + "sourceId": "alias-match", "targetNamespace": "space_1", "targetType": "resolvetype", "targetId": "alias-match-newid" @@ -561,6 +562,7 @@ "type": "legacy-url-alias", "updated_at": "2017-09-21T18:51:23.794Z", "legacy-url-alias": { + "sourceId": "disabled", "targetNamespace": "space_1", "targetType": "resolvetype", "targetId": "alias-match-newid", @@ -611,6 +613,7 @@ "type": "legacy-url-alias", "updated_at": "2017-09-21T18:51:23.794Z", "legacy-url-alias": { + "sourceId": "conflict", "targetNamespace": "space_1", "targetType": "resolvetype", "targetId": "conflict-newid" diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 5ce6c0ce6b7c5..ed52be26c7e53 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -388,6 +388,9 @@ }, "type": "sharedtype", "namespaces": ["default"], + "references": [ + { "type": "sharedtype", "id": "each_space", "name": "refname" } + ], "updated_at": "2017-09-21T18:59:16.270Z" }, "type": "doc" @@ -405,12 +408,33 @@ }, "type": "sharedtype", "namespaces": ["space_1"], + "references": [ + { "type": "sharedtype", "id": "each_space", "name": "refname" } + ], "updated_at": "2017-09-21T18:59:16.270Z" }, "type": "doc" } } +{ + "type": "doc", + "value": { + "id": "legacy-url-alias:space_1:sharedtype:default_only", + "index": ".kibana", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "sourceId": "default_only", + "targetNamespace": "space_1", + "targetType": "sharedtype", + "targetId": "space_1_only" + } + } + } +} + { "type": "doc", "value": { @@ -422,12 +446,52 @@ }, "type": "sharedtype", "namespaces": ["space_2"], + "references": [ + { "type": "sharedtype", "id": "each_space", "name": "refname" } + ], "updated_at": "2017-09-21T18:59:16.270Z" }, "type": "doc" } } +{ + "type": "doc", + "value": { + "id": "legacy-url-alias:space_2:sharedtype:default_only", + "index": ".kibana", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "sourceId": "default_only", + "targetNamespace": "space_2", + "targetType": "sharedtype", + "targetId": "space_2_only" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "legacy-url-alias:other_space:sharedtype:default_only", + "index": ".kibana", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "sourceId": "default_only", + "targetNamespace": "other_space", + "targetType": "sharedtype", + "targetId": "other_id", + "disabled": true + } + } + } +} + { "type": "doc", "value": { @@ -490,6 +554,12 @@ }, "type": "sharedtype", "namespaces": ["default", "space_1", "space_2"], + "references": [ + { "type": "sharedtype", "id": "default_only", "name": "refname" }, + { "type": "sharedtype", "id": "space_1_only", "name": "refname" }, + { "type": "sharedtype", "id": "space_2_only", "name": "refname" }, + { "type": "sharedtype", "id": "all_spaces", "name": "refname" } + ], "updated_at": "2017-09-21T18:59:16.270Z" }, "type": "doc" diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index f26edf71b482c..e264e574a3cea 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -37,7 +37,10 @@ interface CopyToSpaceTests { withConflictsResponse: (resp: TestResponse) => Promise; noConflictsResponse: (resp: TestResponse) => Promise; }; - multiNamespaceTestCases: (overwrite: boolean) => CopyToSpaceMultiNamespaceTest[]; + multiNamespaceTestCases: ( + overwrite: boolean, + createNewCopies: boolean + ) => CopyToSpaceMultiNamespaceTest[]; } interface CopyToSpaceTestDefinition { @@ -427,7 +430,7 @@ export function copyToSpaceTestSuiteFactory( const createMultiNamespaceTestCases = ( spaceId: string, outcome: 'authorized' | 'unauthorizedRead' | 'unauthorizedWrite' | 'noAccess' = 'authorized' - ) => (overwrite: boolean): CopyToSpaceMultiNamespaceTest[] => { + ) => (overwrite: boolean, createNewCopies: boolean): CopyToSpaceMultiNamespaceTest[] => { // the status code of the HTTP response differs depending on the error type // a 403 error actually comes back as an HTTP 200 response const statusCode = outcome === 'noAccess' ? 403 : 200; @@ -451,6 +454,17 @@ export function copyToSpaceTestSuiteFactory( }); }; + const expectNewCopyResponse = (response: TestResponse, sourceId: string, title: string) => { + const { success, successCount, successResults, errors } = getResult(response); + expect(success).to.eql(true); + expect(successCount).to.eql(1); + const destinationId = successResults![0].destinationId; + expect(destinationId).to.match(v4); + const meta = { title, icon: 'beaker' }; + expect(successResults).to.eql([{ type, id: sourceId, meta, destinationId }]); + expect(errors).to.be(undefined); + }; + return [ { testTitle: 'copying with no conflict', @@ -458,14 +472,10 @@ export function copyToSpaceTestSuiteFactory( statusCode, response: async (response: TestResponse) => { if (outcome === 'authorized') { - const { success, successCount, successResults, errors } = getResult(response); - expect(success).to.eql(true); - expect(successCount).to.eql(1); - const destinationId = successResults![0].destinationId; - expect(destinationId).to.match(v4); - const meta = { title: 'A shared saved-object in one space', icon: 'beaker' }; - expect(successResults).to.eql([{ type, id: noConflictId, meta, destinationId }]); - expect(errors).to.be(undefined); + const title = 'A shared saved-object in one space'; + // It doesn't matter if createNewCopies is enabled or not, a new copy will be created because two objects cannot exist with the same ID. + // Note: if createNewCopies is disabled, the new object will have an originId property that matches the source ID, but this is not included in the HTTP response. + expectNewCopyResponse(response, noConflictId, title); } else if (outcome === 'noAccess') { expectRouteForbiddenResponse(response); } else { @@ -479,22 +489,23 @@ export function copyToSpaceTestSuiteFactory( objects: [{ type, id: exactMatchId }], statusCode, response: async (response: TestResponse) => { - if (outcome === 'authorized') { + if (outcome === 'authorized' || (outcome === 'unauthorizedWrite' && !createNewCopies)) { + // If the user is authorized to read in the current space, and is authorized to read in the destination space but not to write + // (outcome === 'unauthorizedWrite'), *and* createNewCopies is not enabled, the object will be skipped (because it already + // exists in the destination space) and the user will encounter an empty success result. + // On the other hand, if the user is authorized to read in the current space but not the destination space (outcome === + // 'unauthorizedRead'), the copy attempt will proceed because they are not aware that the object already exists in the + // destination space. In that case, they will encounter a 403 error. const { success, successCount, successResults, errors } = getResult(response); const title = 'A shared saved-object in the default, space_1, and space_2 spaces'; - const meta = { title, icon: 'beaker' }; - if (overwrite) { - expect(success).to.eql(true); - expect(successCount).to.eql(1); - expect(successResults).to.eql([{ type, id: exactMatchId, meta, overwrite: true }]); - expect(errors).to.be(undefined); + if (createNewCopies) { + expectNewCopyResponse(response, exactMatchId, title); } else { - expect(success).to.eql(false); + // It doesn't matter if overwrite is enabled or not, the object will not be copied because it already exists in the destination space + expect(success).to.eql(true); expect(successCount).to.eql(0); expect(successResults).to.be(undefined); - expect(errors).to.eql([ - { error: { type: 'conflict' }, type, id: exactMatchId, title, meta }, - ]); + expect(errors).to.be(undefined); } } else if (outcome === 'noAccess') { expectRouteForbiddenResponse(response); @@ -514,7 +525,9 @@ export function copyToSpaceTestSuiteFactory( const title = 'A shared saved-object in one space'; const meta = { title, icon: 'beaker' }; const destinationId = 'conflict_1_space_2'; - if (overwrite) { + if (createNewCopies) { + expectNewCopyResponse(response, inexactMatchId, title); + } else if (overwrite) { expect(success).to.eql(true); expect(successCount).to.eql(1); expect(successResults).to.eql([ @@ -550,27 +563,34 @@ export function copyToSpaceTestSuiteFactory( response: async (response: TestResponse) => { if (outcome === 'authorized') { const { success, successCount, successResults, errors } = getResult(response); - const updatedAt = '2017-09-21T18:59:16.270Z'; - const destinations = [ - // response should be sorted by updatedAt in descending order - { id: 'conflict_2_space_2', title: 'A shared saved-object in one space', updatedAt }, - { id: 'conflict_2_all', title: 'A shared saved-object in all spaces', updatedAt }, - ]; - expect(success).to.eql(false); - expect(successCount).to.eql(0); - expect(successResults).to.be(undefined); - expect(errors).to.eql([ - { - error: { type: 'ambiguous_conflict', destinations }, - type, - id: ambiguousConflictId, - title: 'A shared saved-object in one space', - meta: { + const title = 'A shared saved-object in one space'; + if (createNewCopies) { + expectNewCopyResponse(response, ambiguousConflictId, title); + } else { + // It doesn't matter if overwrite is enabled or not, the object will not be copied because there are two matches in the destination space + const updatedAt = '2017-09-21T18:59:16.270Z'; + const destinations = [ + // response should be sorted by updatedAt in descending order + { + id: 'conflict_2_space_2', title: 'A shared saved-object in one space', - icon: 'beaker', + updatedAt, }, - }, - ]); + { id: 'conflict_2_all', title: 'A shared saved-object in all spaces', updatedAt }, + ]; + expect(success).to.eql(false); + expect(successCount).to.eql(0); + expect(successResults).to.be(undefined); + expect(errors).to.eql([ + { + error: { type: 'ambiguous_conflict', destinations }, + type, + id: ambiguousConflictId, + title, + meta: { title, icon: 'beaker' }, + }, + ]); + } } else if (outcome === 'noAccess') { expectRouteForbiddenResponse(response); } else { @@ -726,15 +746,19 @@ export function copyToSpaceTestSuiteFactory( }); }); - [false, true].forEach((overwrite) => { + [ + [false, false], + [false, true], // createNewCopies enabled + [true, false], // overwrite enabled + // we don't specify tese cases with both overwrite and createNewCopies enabled, since overwrite won't matter in that scenario + ].forEach(([overwrite, createNewCopies]) => { const spaces = ['space_2']; const includeReferences = false; - const createNewCopies = false; - describe(`multi-namespace types with overwrite=${overwrite}`, () => { + describe(`multi-namespace types with overwrite=${overwrite} and createNewCopies=${createNewCopies}`, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - const testCases = tests.multiNamespaceTestCases(overwrite); + const testCases = tests.multiNamespaceTestCases(overwrite, createNewCopies); testCases.forEach(({ testTitle, objects, statusCode, response }) => { it(`should return ${statusCode} when ${testTitle}`, async () => { return supertest diff --git a/x-pack/test/spaces_api_integration/common/suites/get_shareable_references.ts b/x-pack/test/spaces_api_integration/common/suites/get_shareable_references.ts new file mode 100644 index 0000000000000..a10e28d52924e --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/get_shareable_references.ts @@ -0,0 +1,270 @@ +/* + * 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 expect from '@kbn/expect'; +import { deepFreeze } from '@kbn/std'; +import { SuperTest } from 'supertest'; +import { + SavedObjectsCollectMultiNamespaceReferencesResponse, + SavedObjectReferenceWithContext, +} from '../../../../../src/core/server'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { + expectResponses, + getUrlPrefix, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { + ExpectResponseBody, + TestDefinition, + TestSuite, +} from '../../../saved_object_api_integration/common/lib/types'; + +export interface GetShareableReferencesTestDefinition extends TestDefinition { + request: { + objects: Array<{ type: string; id: string }>; + }; +} +export type GetShareableReferencesTestSuite = TestSuite; +export interface GetShareableReferencesTestCase { + objects: Array<{ type: string; id: string }>; + expectedResults: SavedObjectReferenceWithContext[]; +} + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +export const TEST_CASE_OBJECTS: Record = deepFreeze({ + SHAREABLE_TYPE: { type: 'sharedtype', id: CASES.EACH_SPACE.id }, // contains references to four other objects + SHAREABLE_TYPE_DOES_NOT_EXIST: { type: 'sharedtype', id: 'does-not-exist' }, + NON_SHAREABLE_TYPE: { type: 'dashboard', id: 'my_dashboard' }, // one of these exists in each space +}); +// Expected results for each space are defined here since they are used in multiple test suites +export const EXPECTED_RESULTS: Record = { + IN_DEFAULT_SPACE: [ + { + ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, + spaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID], + inboundReferences: [{ type: 'sharedtype', id: CASES.DEFAULT_ONLY.id, name: 'refname' }], // only reflects inbound reference that exist in the default space + }, + { + ...TEST_CASE_OBJECTS.SHAREABLE_TYPE_DOES_NOT_EXIST, + spaces: [], + inboundReferences: [], + isMissing: true, // doesn't exist anywhere + }, + { ...TEST_CASE_OBJECTS.NON_SHAREABLE_TYPE, spaces: [], inboundReferences: [] }, // not missing, but has an empty spaces array because it is not a shareable type + { + type: 'sharedtype', + id: CASES.DEFAULT_ONLY.id, + spaces: [DEFAULT_SPACE_ID], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + spacesWithMatchingAliases: [SPACE_1_ID, SPACE_2_ID], // aliases with a matching targetType and sourceId exist in two other spaces + }, + { + type: 'sharedtype', + id: CASES.SPACE_1_ONLY.id, + spaces: [], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + isMissing: true, // doesn't exist in the default space + }, + { + type: 'sharedtype', + id: CASES.SPACE_2_ONLY.id, + spaces: [], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + isMissing: true, // doesn't exist in the default space + }, + { + type: 'sharedtype', + id: CASES.ALL_SPACES.id, + spaces: ['*'], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + }, + ], + IN_SPACE_1: [ + { + ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, + spaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID], + inboundReferences: [{ type: 'sharedtype', id: CASES.SPACE_1_ONLY.id, name: 'refname' }], // only reflects inbound reference that exist in space 1 + }, + { + ...TEST_CASE_OBJECTS.SHAREABLE_TYPE_DOES_NOT_EXIST, + spaces: [], + inboundReferences: [], + isMissing: true, // doesn't exist anywhere + }, + { ...TEST_CASE_OBJECTS.NON_SHAREABLE_TYPE, spaces: [], inboundReferences: [] }, // not missing, but has an empty spaces array because it is not a shareable type + { + type: 'sharedtype', + id: CASES.DEFAULT_ONLY.id, + spaces: [], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + isMissing: true, // doesn't exist in space 1 + }, + { + type: 'sharedtype', + id: CASES.SPACE_1_ONLY.id, + spaces: [SPACE_1_ID], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + }, + { + type: 'sharedtype', + id: CASES.SPACE_2_ONLY.id, + spaces: [], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + isMissing: true, // doesn't exist in space 1 + }, + { + type: 'sharedtype', + id: CASES.ALL_SPACES.id, + spaces: ['*'], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + }, + ], + IN_SPACE_2: [ + { + ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, + spaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID], + inboundReferences: [{ type: 'sharedtype', id: CASES.SPACE_2_ONLY.id, name: 'refname' }], // only reflects inbound reference that exist in space 2 + }, + { + ...TEST_CASE_OBJECTS.SHAREABLE_TYPE_DOES_NOT_EXIST, + spaces: [], + inboundReferences: [], + isMissing: true, // doesn't exist anywhere + }, + { ...TEST_CASE_OBJECTS.NON_SHAREABLE_TYPE, spaces: [], inboundReferences: [] }, // not missing, but has an empty spaces array because it is not a shareable type + { + type: 'sharedtype', + id: CASES.DEFAULT_ONLY.id, + spaces: [], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + isMissing: true, // doesn't exist in space 2 + }, + { + type: 'sharedtype', + id: CASES.SPACE_1_ONLY.id, + spaces: [], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + isMissing: true, // doesn't exist in space 2 + }, + { + type: 'sharedtype', + id: CASES.SPACE_2_ONLY.id, + spaces: [SPACE_2_ID], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + }, + { + type: 'sharedtype', + id: CASES.ALL_SPACES.id, + spaces: ['*'], + inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }], + }, + ], +}; + +const createRequest = ({ objects }: GetShareableReferencesTestCase) => ({ objects }); +const getTestTitle = ({ objects }: GetShareableReferencesTestCase) => { + const objStr = objects.map(({ type, id }) => `${type}:${id}`).join(','); + return `{objects: [${objStr}]}`; +}; +const getRedactedSpaces = (authorizedSpace: string | undefined, spaces: string[]) => { + if (!authorizedSpace) { + return spaces; // if authorizedSpace is undefined, we should not redact any spaces + } + const redactedSpaces = spaces.map((x) => (x !== authorizedSpace && x !== '*' ? '?' : x)); + return redactedSpaces.sort((a, b) => (a === '?' ? 1 : b === '?' ? -1 : 0)); // unknown spaces are always at the end of the array +}; + +export function getShareableReferencesTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectForbidden = expectResponses.forbiddenTypes('share_to_space'); + const expectResponseBody = ( + testCase: GetShareableReferencesTestCase, + statusCode: 200 | 403, + authorizedSpace?: string + ): ExpectResponseBody => async (response: Record) => { + if (statusCode === 403) { + const types = testCase.objects.map((x) => x.type); + await expectForbidden(types)(response); + } else { + const { expectedResults } = testCase; + const apiResponse = response.body as SavedObjectsCollectMultiNamespaceReferencesResponse; + expect(apiResponse.objects).to.have.length(expectedResults.length); + expectedResults.forEach((expectedResult, i) => { + const { spaces, spacesWithMatchingAliases } = expectedResult; + const expectedSpaces = getRedactedSpaces(authorizedSpace, spaces); + const expectedSpacesWithMatchingAliases = + spacesWithMatchingAliases && + getRedactedSpaces(authorizedSpace, spacesWithMatchingAliases); + const expected = { + ...expectedResult, + spaces: expectedSpaces, + ...(expectedSpacesWithMatchingAliases && { + spacesWithMatchingAliases: expectedSpacesWithMatchingAliases, + }), + }; + expect(apiResponse.objects[i]).to.eql(expected); + }); + } + }; + const createTestDefinitions = ( + testCases: GetShareableReferencesTestCase | GetShareableReferencesTestCase[], + forbidden: boolean, + options: { + /** If defined, will expect results to have redacted any spaces that do not match this one. */ + authorizedSpace?: string; + responseBodyOverride?: ExpectResponseBody; + } = {} + ): GetShareableReferencesTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + return cases.map((x) => ({ + title: getTestTitle(x), + responseStatusCode, + request: createRequest(x), + responseBody: + options?.responseBodyOverride || + expectResponseBody(x, responseStatusCode, options.authorizedSpace), + })); + }; + + const makeGetShareableReferencesTest = (describeFn: Mocha.SuiteFunction) => ( + description: string, + definition: GetShareableReferencesTestSuite + ) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request; + await supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_get_shareable_references`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeGetShareableReferencesTest(describe); + // @ts-ignore + addTests.only = makeGetShareableReferencesTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} diff --git a/x-pack/test/spaces_api_integration/common/suites/share_add.ts b/x-pack/test/spaces_api_integration/common/suites/share_add.ts deleted file mode 100644 index bec951bff67a5..0000000000000 --- a/x-pack/test/spaces_api_integration/common/suites/share_add.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import { SuperTest } from 'supertest'; -import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; -import { SPACES } from '../lib/spaces'; -import { - expectResponses, - getUrlPrefix, -} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; -import { - ExpectResponseBody, - TestDefinition, - TestSuite, -} from '../../../saved_object_api_integration/common/lib/types'; - -export interface ShareAddTestDefinition extends TestDefinition { - request: { spaces: string[]; object: { type: string; id: string } }; -} -export type ShareAddTestSuite = TestSuite; -export interface ShareAddTestCase { - id: string; - namespaces: string[]; - failure?: 400 | 403 | 404; -} - -const TYPE = 'sharedtype'; -const createRequest = ({ id, namespaces }: ShareAddTestCase) => ({ - spaces: namespaces, - object: { type: TYPE, id }, -}); -const getTestTitle = ({ id, namespaces }: ShareAddTestCase) => - `{id: ${id}, namespaces: [${namespaces.join(',')}]}`; - -export function shareAddTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbiddenTypes('share_to_space'); - const expectResponseBody = (testCase: ShareAddTestCase): ExpectResponseBody => async ( - response: Record - ) => { - const { id, failure } = testCase; - const object = response.body; - if (failure === 403) { - await expectForbidden(TYPE)(response); - } else if (failure === 404) { - const error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); - expect(object.error).to.eql(error.output.payload.error); - expect(object.statusCode).to.eql(error.output.payload.statusCode); - } else { - // success - expect(object).to.eql({}); - } - }; - const createTestDefinitions = ( - testCases: ShareAddTestCase | ShareAddTestCase[], - forbidden: boolean, - options?: { - responseBodyOverride?: ExpectResponseBody; - } - ): ShareAddTestDefinition[] => { - let cases = Array.isArray(testCases) ? testCases : [testCases]; - if (forbidden) { - // override the expected result in each test case - cases = cases.map((x) => ({ ...x, failure: 403 })); - } - return cases.map((x) => ({ - title: getTestTitle(x), - responseStatusCode: x.failure ?? 204, - request: createRequest(x), - responseBody: options?.responseBodyOverride || expectResponseBody(x), - })); - }; - - const makeShareAddTest = (describeFn: Mocha.SuiteFunction) => ( - description: string, - definition: ShareAddTestSuite - ) => { - const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; - - describeFn(description, () => { - before(() => esArchiver.load('saved_objects/spaces')); - after(() => esArchiver.unload('saved_objects/spaces')); - - for (const test of tests) { - it(`should return ${test.responseStatusCode} ${test.title}`, async () => { - const requestBody = test.request; - await supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_share_saved_object_add`) - .auth(user?.username, user?.password) - .send(requestBody) - .expect(test.responseStatusCode) - .then(test.responseBody); - }); - } - }); - }; - - const addTests = makeShareAddTest(describe); - // @ts-ignore - addTests.only = makeShareAddTest(describe.only); - - return { - addTests, - createTestDefinitions, - }; -} diff --git a/x-pack/test/spaces_api_integration/common/suites/share_remove.ts b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts deleted file mode 100644 index 8b29c7e4d8bed..0000000000000 --- a/x-pack/test/spaces_api_integration/common/suites/share_remove.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import { SuperTest } from 'supertest'; -import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; -import { SPACES } from '../lib/spaces'; -import { - expectResponses, - getUrlPrefix, - getTestTitle, -} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; -import { - ExpectResponseBody, - TestDefinition, - TestSuite, -} from '../../../saved_object_api_integration/common/lib/types'; - -export interface ShareRemoveTestDefinition extends TestDefinition { - request: { spaces: string[]; object: { type: string; id: string } }; -} -export type ShareRemoveTestSuite = TestSuite; -export interface ShareRemoveTestCase { - id: string; - namespaces: string[]; - failure?: 400 | 403 | 404; -} - -const TYPE = 'sharedtype'; -const createRequest = ({ id, namespaces }: ShareRemoveTestCase) => ({ - spaces: namespaces, - object: { type: TYPE, id }, -}); - -export function shareRemoveTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbiddenTypes('share_to_space'); - const expectResponseBody = (testCase: ShareRemoveTestCase): ExpectResponseBody => async ( - response: Record - ) => { - const { id, failure } = testCase; - const object = response.body; - if (failure === 403) { - await expectForbidden(TYPE)(response); - } else if (failure === 404) { - const error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); - expect(object.error).to.eql(error.output.payload.error); - expect(object.statusCode).to.eql(error.output.payload.statusCode); - } else { - // success - expect(object).to.eql({}); - } - }; - const createTestDefinitions = ( - testCases: ShareRemoveTestCase | ShareRemoveTestCase[], - forbidden: boolean, - options?: { - responseBodyOverride?: ExpectResponseBody; - } - ): ShareRemoveTestDefinition[] => { - let cases = Array.isArray(testCases) ? testCases : [testCases]; - if (forbidden) { - // override the expected result in each test case - cases = cases.map((x) => ({ ...x, failure: 403 })); - } - return cases.map((x) => ({ - title: getTestTitle({ ...x, type: TYPE }), - responseStatusCode: x.failure ?? 204, - request: createRequest(x), - responseBody: options?.responseBodyOverride || expectResponseBody(x), - })); - }; - - const makeShareRemoveTest = (describeFn: Mocha.SuiteFunction) => ( - description: string, - definition: ShareRemoveTestSuite - ) => { - const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; - - describeFn(description, () => { - before(() => esArchiver.load('saved_objects/spaces')); - after(() => esArchiver.unload('saved_objects/spaces')); - - for (const test of tests) { - it(`should return ${test.responseStatusCode} ${test.title}`, async () => { - const requestBody = test.request; - await supertest - .post(`${getUrlPrefix(spaceId)}/api/spaces/_share_saved_object_remove`) - .auth(user?.username, user?.password) - .send(requestBody) - .expect(test.responseStatusCode) - .then(test.responseBody); - }); - } - }); - }; - - const addTests = makeShareRemoveTest(describe); - // @ts-ignore - addTests.only = makeShareRemoveTest(describe.only); - - return { - addTests, - createTestDefinitions, - }; -} diff --git a/x-pack/test/spaces_api_integration/common/suites/update_objects_spaces.ts b/x-pack/test/spaces_api_integration/common/suites/update_objects_spaces.ts new file mode 100644 index 0000000000000..7664deb6b0bdf --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/update_objects_spaces.ts @@ -0,0 +1,142 @@ +/* + * 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 expect from '@kbn/expect'; +import { without, uniq } from 'lodash'; +import { SuperTest } from 'supertest'; +import { + SavedObjectsErrorHelpers, + SavedObjectsUpdateObjectsSpacesResponse, +} from '../../../../../src/core/server'; +import { SPACES } from '../lib/spaces'; +import { + expectResponses, + getUrlPrefix, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { + ExpectResponseBody, + TestDefinition, + TestSuite, +} from '../../../saved_object_api_integration/common/lib/types'; + +export interface UpdateObjectsSpacesTestDefinition extends TestDefinition { + request: { + objects: Array<{ type: string; id: string }>; + spacesToAdd: string[]; + spacesToRemove: string[]; + }; +} +export type UpdateObjectsSpacesTestSuite = TestSuite; +export interface UpdateObjectsSpacesTestCase { + objects: Array<{ + id: string; + existingNamespaces: string[]; + failure?: 400 | 404; + }>; + spacesToAdd: string[]; + spacesToRemove: string[]; +} + +const TYPE = 'sharedtype'; +const createRequest = ({ objects, spacesToAdd, spacesToRemove }: UpdateObjectsSpacesTestCase) => ({ + objects: objects.map(({ id }) => ({ type: TYPE, id })), + spacesToAdd, + spacesToRemove, +}); +const getTestTitle = ({ objects, spacesToAdd, spacesToRemove }: UpdateObjectsSpacesTestCase) => { + const objStr = objects.map(({ id }) => id).join(','); + const addStr = spacesToAdd.join(','); + const remStr = spacesToRemove.join(','); + return `{objects: [${objStr}], spacesToAdd: [${addStr}], spacesToRemove: [${remStr}]}`; +}; + +export function updateObjectsSpacesTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectForbidden = expectResponses.forbiddenTypes('share_to_space'); + const expectResponseBody = ( + testCase: UpdateObjectsSpacesTestCase, + statusCode: 200 | 403, + authorizedSpace?: string + ): ExpectResponseBody => async (response: Record) => { + if (statusCode === 403) { + await expectForbidden(TYPE)(response); + } else { + const { objects, spacesToAdd, spacesToRemove } = testCase; + const apiResponse = response.body as SavedObjectsUpdateObjectsSpacesResponse; + objects.forEach(({ id, existingNamespaces, failure }, i) => { + const object = apiResponse.objects[i]; + if (failure === 404) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); + expect(object.error).to.eql(error.output.payload); + } else { + // success + const expectedSpaces = without( + uniq([...existingNamespaces, ...spacesToAdd]), + ...spacesToRemove + ).map((x) => (authorizedSpace && x !== authorizedSpace && x !== '*' ? '?' : x)); + + const result = apiResponse.objects[i]; + expect(result.type).to.eql(TYPE); + expect(result.id).to.eql(id); + expect(result.spaces.sort()).to.eql(expectedSpaces.sort()); + } + }); + } + }; + const createTestDefinitions = ( + testCases: UpdateObjectsSpacesTestCase | UpdateObjectsSpacesTestCase[], + forbidden: boolean, + options: { + /** If defined, will expect results to have redacted any spaces that do not match this one. */ + authorizedSpace?: string; + responseBodyOverride?: ExpectResponseBody; + } = {} + ): UpdateObjectsSpacesTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + return cases.map((x) => ({ + title: getTestTitle(x), + responseStatusCode, + request: createRequest(x), + responseBody: + options?.responseBodyOverride || + expectResponseBody(x, responseStatusCode, options.authorizedSpace), + })); + }; + + const makeUpdateObjectsSpacesTest = (describeFn: Mocha.SuiteFunction) => ( + description: string, + definition: UpdateObjectsSpacesTestSuite + ) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request; + await supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_update_objects_spaces`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeUpdateObjectsSpacesTest(describe); + // @ts-ignore + addTests.only = makeUpdateObjectsSpacesTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_shareable_references.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_shareable_references.ts new file mode 100644 index 0000000000000..d3466dd511e82 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_shareable_references.ts @@ -0,0 +1,86 @@ +/* + * 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 { getTestScenarios } from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; +import { + getShareableReferencesTestSuiteFactory, + GetShareableReferencesTestCase, + GetShareableReferencesTestDefinition, + TEST_CASE_OBJECTS, + EXPECTED_RESULTS, +} from '../../common/suites/get_shareable_references'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +const createTestCases = (spaceId: string): GetShareableReferencesTestCase[] => { + const objects = [ + // requested objects are the same for each space + TEST_CASE_OBJECTS.SHAREABLE_TYPE, + TEST_CASE_OBJECTS.SHAREABLE_TYPE_DOES_NOT_EXIST, + TEST_CASE_OBJECTS.NON_SHAREABLE_TYPE, + ]; + + if (spaceId === DEFAULT_SPACE_ID) { + return [{ objects, expectedResults: EXPECTED_RESULTS.IN_DEFAULT_SPACE }]; + } else if (spaceId === SPACE_1_ID) { + return [{ objects, expectedResults: EXPECTED_RESULTS.IN_SPACE_1 }]; + } else if (spaceId === SPACE_2_ID) { + return [{ objects, expectedResults: EXPECTED_RESULTS.IN_SPACE_2 }]; + } + throw new Error(`Unexpected test case for space '${spaceId}'!`); +}; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = getShareableReferencesTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return { + unauthorized: createTestDefinitions(testCases, true), + authorizedThisSpace: createTestDefinitions(testCases, false, { authorizedSpace: spaceId }), + authorizedGlobally: createTestDefinitions(testCases, false), + }; + }; + + describe('_get_shareable_references', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` targeting the ${spaceId} space`; + const { unauthorized, authorizedThisSpace, authorizedGlobally } = createTests(spaceId); + const _addTests = (user: TestUser, tests: GetShareableReferencesTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; + + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach((user) => { + _addTests(user, unauthorized); + }); + _addTests(users.allAtSpace, authorizedThisSpace); + [users.dualAll, users.allGlobally, users.superuser].forEach((user) => { + _addTests(user, authorizedGlobally); + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts index 3a775b0579a20..4bb4d10eaabf8 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts @@ -25,9 +25,9 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); + loadTestFile(require.resolve('./get_shareable_references')); loadTestFile(require.resolve('./get')); - loadTestFile(require.resolve('./share_add')); - loadTestFile(require.resolve('./share_remove')); loadTestFile(require.resolve('./update')); + loadTestFile(require.resolve('./update_objects_spaces')); }); } diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts deleted file mode 100644 index 050cb81874cd3..0000000000000 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; 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 '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; -import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; -import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; -import { - shareAddTestSuiteFactory, - ShareAddTestDefinition, - ShareAddTestCase, -} from '../../common/suites/share_add'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; - -const { - DEFAULT: { spaceId: DEFAULT_SPACE_ID }, - SPACE_1: { spaceId: SPACE_1_ID }, - SPACE_2: { spaceId: SPACE_2_ID }, -} = SPACES; -const { fail404 } = testCaseFailures; - -const createTestCases = (spaceId: string) => { - const namespaces = [spaceId]; - return [ - // Test cases to check adding the target namespace to different saved objects - { ...CASES.DEFAULT_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, - { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, - { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, - { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, - { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, - { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, - { ...CASES.ALL_SPACES, namespaces }, - { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, - // Test case to check adding all spaces ("*") to a saved object - { ...CASES.EACH_SPACE, namespaces: ['*'] }, - // Test cases to check adding multiple namespaces to different saved objects that exist in one space - // These are non-exhaustive, they only check cases for adding two additional namespaces to a saved object - // More permutations are covered in the corresponding spaces_only test suite - { - ...CASES.DEFAULT_ONLY, - namespaces: [SPACE_1_ID, SPACE_2_ID], - ...fail404(spaceId !== DEFAULT_SPACE_ID), - }, - { - ...CASES.SPACE_1_ONLY, - namespaces: [DEFAULT_SPACE_ID, SPACE_2_ID], - ...fail404(spaceId !== SPACE_1_ID), - }, - { - ...CASES.SPACE_2_ONLY, - namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID], - ...fail404(spaceId !== SPACE_2_ID), - }, - ]; -}; -const calculateSingleSpaceAuthZ = ( - testCases: ReturnType, - spaceId: string -) => { - const targetsAllSpaces: ShareAddTestCase[] = []; - const targetsOtherSpace: ShareAddTestCase[] = []; - const doesntExistInThisSpace: ShareAddTestCase[] = []; - const existsInThisSpace: ShareAddTestCase[] = []; - - for (const testCase of testCases) { - const { namespaces, existingNamespaces } = testCase; - if (namespaces.includes('*')) { - targetsAllSpaces.push(testCase); - } else if (!namespaces.includes(spaceId) || namespaces.length > 1) { - targetsOtherSpace.push(testCase); - } else if (!existingNamespaces.includes(spaceId)) { - doesntExistInThisSpace.push(testCase); - } else { - existsInThisSpace.push(testCase); - } - } - return { targetsAllSpaces, targetsOtherSpace, doesntExistInThisSpace, existsInThisSpace }; -}; -// eslint-disable-next-line import/no-default-export -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - - const { addTests, createTestDefinitions } = shareAddTestSuiteFactory(esArchiver, supertest); - const createTests = (spaceId: string) => { - const testCases = createTestCases(spaceId); - const thisSpace = calculateSingleSpaceAuthZ(testCases, spaceId); - const otherSpaceId = spaceId === DEFAULT_SPACE_ID ? SPACE_1_ID : DEFAULT_SPACE_ID; - const otherSpace = calculateSingleSpaceAuthZ(testCases, otherSpaceId); - return { - unauthorized: createTestDefinitions(testCases, true), - authorizedInSpace: [ - createTestDefinitions(thisSpace.targetsAllSpaces, true), - createTestDefinitions(thisSpace.targetsOtherSpace, true), - createTestDefinitions(thisSpace.doesntExistInThisSpace, false), - createTestDefinitions(thisSpace.existsInThisSpace, false), - ].flat(), - authorizedInOtherSpace: [ - createTestDefinitions(thisSpace.targetsAllSpaces, true), - createTestDefinitions(otherSpace.targetsOtherSpace, true), - // If the preflight GET request fails, it will return a 404 error; users who are authorized to share saved objects in the target - // space(s) but are not authorized to share saved objects in this space will see a 403 error instead of 404. This is a safeguard to - // prevent potential information disclosure of the spaces that a given saved object may exist in. - createTestDefinitions(otherSpace.doesntExistInThisSpace, true), - createTestDefinitions(otherSpace.existsInThisSpace, false), - ].flat(), - authorized: createTestDefinitions(testCases, false), - }; - }; - - describe('_share_saved_object_add', () => { - getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { - const suffix = ` targeting the ${spaceId} space`; - const { unauthorized, authorizedInSpace, authorizedInOtherSpace, authorized } = createTests( - spaceId - ); - const _addTests = (user: TestUser, tests: ShareAddTestDefinition[]) => { - addTests(`${user.description}${suffix}`, { user, spaceId, tests }); - }; - - [ - users.noAccess, - users.legacyAll, - users.dualRead, - users.readGlobally, - users.readAtSpace, - ].forEach((user) => { - _addTests(user, unauthorized); - }); - _addTests(users.allAtSpace, authorizedInSpace); - _addTests(users.allAtOtherSpace, authorizedInOtherSpace); - [users.dualAll, users.allGlobally, users.superuser].forEach((user) => { - _addTests(user, authorized); - }); - }); - }); -} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts deleted file mode 100644 index a5f18cf129d4c..0000000000000 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; 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 '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; -import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; -import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; -import { - shareRemoveTestSuiteFactory, - ShareRemoveTestCase, - ShareRemoveTestDefinition, -} from '../../common/suites/share_remove'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; - -const { - DEFAULT: { spaceId: DEFAULT_SPACE_ID }, - SPACE_1: { spaceId: SPACE_1_ID }, - SPACE_2: { spaceId: SPACE_2_ID }, -} = SPACES; -const { fail404 } = testCaseFailures; - -const createTestCases = (spaceId: string) => { - // Test cases to check removing the target namespace from different saved objects - let namespaces = [spaceId]; - const singleSpace = [ - { id: CASES.DEFAULT_ONLY.id, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, - { id: CASES.SPACE_1_ONLY.id, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, - { id: CASES.SPACE_2_ONLY.id, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, - { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404(spaceId === SPACE_2_ID) }, - { id: CASES.DEFAULT_AND_SPACE_2.id, namespaces, ...fail404(spaceId === SPACE_1_ID) }, - { id: CASES.SPACE_1_AND_SPACE_2.id, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, - { id: CASES.EACH_SPACE.id, namespaces }, - { id: CASES.DOES_NOT_EXIST.id, namespaces, ...fail404() }, - ] as ShareRemoveTestCase[]; - - namespaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; - const multipleSpaces = [ - // Test case to check removing all spaces from a saved object that exists in all spaces; - // It fails the second time because the object no longer exists - { ...CASES.ALL_SPACES, namespaces: ['*'] }, - { ...CASES.ALL_SPACES, namespaces: ['*'], ...fail404() }, - // Test cases to check removing all three namespaces from different saved objects that exist in two spaces - // These are non-exhaustive, they only check some cases -- each object will result in a 404, either because - // it never existed in the target namespace, or it was removed in one of the test cases above - // More permutations are covered in the corresponding spaces_only test suite - { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404() }, - { id: CASES.DEFAULT_AND_SPACE_2.id, namespaces, ...fail404() }, - { id: CASES.SPACE_1_AND_SPACE_2.id, namespaces, ...fail404() }, - ] as ShareRemoveTestCase[]; - - const allCases = singleSpace.concat(multipleSpaces); - return { singleSpace, multipleSpaces, allCases }; -}; - -// eslint-disable-next-line import/no-default-export -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - - const { addTests, createTestDefinitions } = shareRemoveTestSuiteFactory(esArchiver, supertest); - const createTests = (spaceId: string) => { - const { singleSpace, multipleSpaces, allCases } = createTestCases(spaceId); - return { - unauthorized: createTestDefinitions(allCases, true), - authorizedThisSpace: [ - createTestDefinitions(singleSpace, false), - createTestDefinitions(multipleSpaces, true), - ].flat(), - authorizedGlobally: createTestDefinitions(allCases, false), - }; - }; - - describe('_share_saved_object_remove', () => { - getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { - const suffix = ` targeting the ${spaceId} space`; - const { unauthorized, authorizedThisSpace, authorizedGlobally } = createTests(spaceId); - const _addTests = (user: TestUser, tests: ShareRemoveTestDefinition[]) => { - addTests(`${user.description}${suffix}`, { user, spaceId, tests }); - }; - - [ - users.noAccess, - users.legacyAll, - users.dualRead, - users.readGlobally, - users.readAtSpace, - users.allAtOtherSpace, - ].forEach((user) => { - _addTests(user, unauthorized); - }); - _addTests(users.allAtSpace, authorizedThisSpace); - [users.dualAll, users.allGlobally, users.superuser].forEach((user) => { - _addTests(user, authorizedGlobally); - }); - }); - }); -} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/update_objects_spaces.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/update_objects_spaces.ts new file mode 100644 index 0000000000000..36f50aa165e72 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/update_objects_spaces.ts @@ -0,0 +1,170 @@ +/* + * 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 '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; +import { + updateObjectsSpacesTestSuiteFactory, + UpdateObjectsSpacesTestDefinition, + UpdateObjectsSpacesTestCase, +} from '../../common/suites/update_objects_spaces'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string): UpdateObjectsSpacesTestCase[] => { + const eachSpace = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + return [ + // Test case to check adding and removing all spaces ("*") to a saved object + { + objects: [CASES.EACH_SPACE], + spacesToAdd: ['*'], + spacesToRemove: [], + }, + { + objects: [{ id: CASES.EACH_SPACE.id, existingNamespaces: [...eachSpace, '*'] }], + spacesToAdd: [], + spacesToRemove: ['*'], + }, + + // Test cases to check adding and removing multiple namespaces to different saved objects that exist in one space + // These are non-exhaustive, they only check cases for adding two additional namespaces to a saved object + // More permutations are covered in the corresponding spaces_only test suite + { + objects: [{ ...CASES.DEFAULT_ONLY, ...fail404(spaceId !== DEFAULT_SPACE_ID) }], + spacesToAdd: [SPACE_1_ID, SPACE_2_ID], + spacesToRemove: [], + }, + { + objects: [{ ...CASES.SPACE_1_ONLY, ...fail404(spaceId !== SPACE_1_ID) }], + spacesToAdd: [DEFAULT_SPACE_ID, SPACE_2_ID], + spacesToRemove: [], + }, + { + objects: [{ ...CASES.SPACE_2_ONLY, ...fail404(spaceId !== SPACE_2_ID) }], + spacesToAdd: [DEFAULT_SPACE_ID, SPACE_1_ID], + spacesToRemove: [], + }, + { + objects: [ + { + id: CASES.DEFAULT_ONLY.id, + existingNamespaces: eachSpace, + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { + id: CASES.SPACE_1_ONLY.id, + existingNamespaces: eachSpace, + ...fail404(spaceId !== SPACE_1_ID), + }, + { + id: CASES.SPACE_2_ONLY.id, + existingNamespaces: eachSpace, + ...fail404(spaceId !== SPACE_2_ID), + }, + ], + spacesToAdd: [], + spacesToRemove: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID], + }, + + // Test cases to check adding and removing the target namespace to different saved objects + { + objects: [ + { ...CASES.DEFAULT_AND_SPACE_1, ...fail404(spaceId === SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_2, ...fail404(spaceId === SPACE_1_ID) }, + { ...CASES.SPACE_1_AND_SPACE_2, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + CASES.ALL_SPACES, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ], + spacesToAdd: [spaceId], + spacesToRemove: [], + }, + { + objects: [ + { ...CASES.DEFAULT_AND_SPACE_1, ...fail404(spaceId === SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_2, ...fail404(spaceId === SPACE_1_ID) }, + { ...CASES.SPACE_1_AND_SPACE_2, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { id: CASES.ALL_SPACES.id, existingNamespaces: ['*', spaceId] }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ], + spacesToAdd: [], + spacesToRemove: [spaceId], + }, + ]; +}; +const calculateSingleSpaceAuthZ = (testCases: UpdateObjectsSpacesTestCase[], spaceId: string) => { + const targetsThisSpace: UpdateObjectsSpacesTestCase[] = []; + const targetsOtherSpace: UpdateObjectsSpacesTestCase[] = []; + + for (const testCase of testCases) { + const { spacesToAdd, spacesToRemove } = testCase; + const spacesToAddOrRemove = [...spacesToAdd, ...spacesToRemove]; + if (spacesToAddOrRemove.length === 1 && spacesToAddOrRemove[0] === spaceId) { + targetsThisSpace.push(testCase); + } else { + targetsOtherSpace.push(testCase); + } + } + return { targetsThisSpace, targetsOtherSpace }; +}; +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = updateObjectsSpacesTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + const { targetsThisSpace, targetsOtherSpace } = calculateSingleSpaceAuthZ(testCases, spaceId); + return { + unauthorized: createTestDefinitions(testCases, true), + authorizedThisSpace: [ + createTestDefinitions(targetsOtherSpace, true), + createTestDefinitions(targetsThisSpace, false, { authorizedSpace: spaceId }), + ].flat(), + authorizedGlobally: createTestDefinitions(testCases, false), + }; + }; + + describe('_update_objects_spaces', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` targeting the ${spaceId} space`; + const { unauthorized, authorizedThisSpace, authorizedGlobally } = createTests(spaceId); + const _addTests = (user: TestUser, tests: UpdateObjectsSpacesTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; + + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach((user) => { + _addTests(user, unauthorized); + }); + _addTests(users.allAtSpace, authorizedThisSpace); + [users.dualAll, users.allGlobally, users.superuser].forEach((user) => { + _addTests(user, authorizedGlobally); + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/get_shareable_references.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/get_shareable_references.ts new file mode 100644 index 0000000000000..5eec1dda83e5a --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/get_shareable_references.ts @@ -0,0 +1,62 @@ +/* + * 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 { getTestScenarios } from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { + getShareableReferencesTestSuiteFactory, + GetShareableReferencesTestCase, + TEST_CASE_OBJECTS, + EXPECTED_RESULTS, +} from '../../common/suites/get_shareable_references'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +const createTestCases = (spaceId: string): GetShareableReferencesTestCase[] => { + const objects = [ + // requested objects are the same for each space + TEST_CASE_OBJECTS.SHAREABLE_TYPE, + TEST_CASE_OBJECTS.SHAREABLE_TYPE_DOES_NOT_EXIST, + TEST_CASE_OBJECTS.NON_SHAREABLE_TYPE, + ]; + + if (spaceId === DEFAULT_SPACE_ID) { + return [{ objects, expectedResults: EXPECTED_RESULTS.IN_DEFAULT_SPACE }]; + } else if (spaceId === SPACE_1_ID) { + return [{ objects, expectedResults: EXPECTED_RESULTS.IN_SPACE_1 }]; + } else if (spaceId === SPACE_2_ID) { + return [{ objects, expectedResults: EXPECTED_RESULTS.IN_SPACE_2 }]; + } + throw new Error(`Unexpected test case for space '${spaceId}'!`); +}; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = getShareableReferencesTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; + + describe('_get_shareable_references', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`targeting the ${spaceId} space`, { spaceId, tests }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts index 6c52f731289e7..489e2c2d22ffa 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts @@ -17,9 +17,9 @@ export default function spacesOnlyTestSuite({ loadTestFile }: FtrProviderContext loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); + loadTestFile(require.resolve('./get_shareable_references')); loadTestFile(require.resolve('./get')); - loadTestFile(require.resolve('./share_add')); - loadTestFile(require.resolve('./share_remove')); loadTestFile(require.resolve('./update')); + loadTestFile(require.resolve('./update_objects_spaces')); }); } diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts deleted file mode 100644 index 77af9221d6b9c..0000000000000 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; 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 '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; -import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; -import { shareAddTestSuiteFactory } from '../../common/suites/share_add'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; - -const { - DEFAULT: { spaceId: DEFAULT_SPACE_ID }, - SPACE_1: { spaceId: SPACE_1_ID }, - SPACE_2: { spaceId: SPACE_2_ID }, -} = SPACES; -const { fail404 } = testCaseFailures; - -/** - * Single-namespace test cases - * @param spaceId the namespace to add to each saved object - */ -const createSingleTestCases = (spaceId: string) => { - const namespaces = ['some-space-id']; - return [ - { ...CASES.DEFAULT_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, - { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, - { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, - { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, - { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, - { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, - { ...CASES.EACH_SPACE, namespaces }, - { ...CASES.ALL_SPACES, namespaces }, - { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, - ]; -}; -/** - * Multi-namespace test cases - * These are non-exhaustive, but they check different permutations of saved objects and spaces to add - */ -const createMultiTestCases = () => { - const eachSpace = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; - const allSpaces = ['*']; - // for each of the cases below, test adding each space and all spaces to the object - const one = [ - { id: CASES.DEFAULT_ONLY.id, namespaces: eachSpace }, - { id: CASES.DEFAULT_ONLY.id, namespaces: allSpaces }, - ]; - const two = [ - { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces: eachSpace }, - { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces: allSpaces }, - ]; - const three = [ - { id: CASES.EACH_SPACE.id, namespaces: eachSpace }, - { id: CASES.EACH_SPACE.id, namespaces: allSpaces }, - ]; - const four = [ - { id: CASES.ALL_SPACES.id, namespaces: eachSpace }, - { id: CASES.ALL_SPACES.id, namespaces: allSpaces }, - ]; - return { one, two, three, four }; -}; - -// eslint-disable-next-line import/no-default-export -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - const { addTests, createTestDefinitions } = shareAddTestSuiteFactory(esArchiver, supertest); - const createSingleTests = (spaceId: string) => { - const testCases = createSingleTestCases(spaceId); - return createTestDefinitions(testCases, false); - }; - const createMultiTests = () => { - const testCases = createMultiTestCases(); - return { - one: createTestDefinitions(testCases.one, false), - two: createTestDefinitions(testCases.two, false), - three: createTestDefinitions(testCases.three, false), - four: createTestDefinitions(testCases.four, false), - }; - }; - - describe('_share_saved_object_add', () => { - getTestScenarios().spaces.forEach(({ spaceId }) => { - const tests = createSingleTests(spaceId); - addTests(`targeting the ${spaceId} space`, { spaceId, tests }); - }); - const { one, two, three, four } = createMultiTests(); - addTests('for a saved object in the default space', { tests: one }); - addTests('for a saved object in the default and space_1 spaces', { tests: two }); - addTests('for a saved object in the default, space_1, and space_2 spaces', { tests: three }); - addTests('for a saved object in all spaces', { tests: four }); - }); -} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts deleted file mode 100644 index 22e18e7308f6b..0000000000000 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; 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 '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; -import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; -import { shareRemoveTestSuiteFactory } from '../../common/suites/share_remove'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; - -const { - DEFAULT: { spaceId: DEFAULT_SPACE_ID }, - SPACE_1: { spaceId: SPACE_1_ID }, - SPACE_2: { spaceId: SPACE_2_ID }, -} = SPACES; -const { fail404 } = testCaseFailures; - -/** - * Single-namespace test cases - * @param spaceId the namespace to remove from each saved object - */ -const createSingleTestCases = (spaceId: string) => { - const namespaces = [spaceId]; - return [ - { ...CASES.DEFAULT_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, - { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, - { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, - { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, - { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, - { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, - { ...CASES.EACH_SPACE, namespaces }, - { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, - ]; -}; -/** - * Multi-namespace test cases - * These are non-exhaustive, but they check different permutations of saved objects and spaces to remove - */ -const createMultiTestCases = () => { - const nonExistentSpaceId = 'does_not_exist'; // space that doesn't exist - let id = CASES.DEFAULT_ONLY.id; - const one = [ - { id, namespaces: [nonExistentSpaceId] }, - { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID] }, - { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this saved object no longer exists - ]; - id = CASES.DEFAULT_AND_SPACE_1.id; - const two = [ - { id, namespaces: [DEFAULT_SPACE_ID, nonExistentSpaceId] }, - // this saved object will not be found in the context of the current namespace ('default') - { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this object's namespaces no longer contains DEFAULT_SPACE_ID - { id, namespaces: [SPACE_1_ID], ...fail404() }, // this object's namespaces does contain SPACE_1_ID - ]; - id = CASES.EACH_SPACE.id; - const three = [ - { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, nonExistentSpaceId] }, - // this saved object will not be found in the context of the current namespace ('default') - { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this object's namespaces no longer contains DEFAULT_SPACE_ID - { id, namespaces: [SPACE_1_ID], ...fail404() }, // this object's namespaces no longer contains SPACE_1_ID - { id, namespaces: [SPACE_2_ID], ...fail404() }, // this object's namespaces does contain SPACE_2_ID - ]; - id = CASES.ALL_SPACES.id; - const four = [ - { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, nonExistentSpaceId] }, - // this saved object will still be found in the context of the current namespace ('default') - { id, namespaces: ['*'] }, - // this object no longer exists - { id, namespaces: ['*'], ...fail404() }, - ]; - return { one, two, three, four }; -}; - -// eslint-disable-next-line import/no-default-export -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - const { addTests, createTestDefinitions } = shareRemoveTestSuiteFactory(esArchiver, supertest); - const createSingleTests = (spaceId: string) => { - const testCases = createSingleTestCases(spaceId); - return createTestDefinitions(testCases, false); - }; - const createMultiTests = () => { - const testCases = createMultiTestCases(); - return { - one: createTestDefinitions(testCases.one, false), - two: createTestDefinitions(testCases.two, false), - three: createTestDefinitions(testCases.three, false), - four: createTestDefinitions(testCases.four, false), - }; - }; - - describe('_share_saved_object_remove', () => { - getTestScenarios().spaces.forEach(({ spaceId }) => { - const tests = createSingleTests(spaceId); - addTests(`targeting the ${spaceId} space`, { spaceId, tests }); - }); - const { one, two, three, four } = createMultiTests(); - addTests('for a saved object in the default space', { tests: one }); - addTests('for a saved object in the default and space_1 spaces', { tests: two }); - addTests('for a saved object in the default, space_1, and space_2 spaces', { tests: three }); - addTests('for a saved object in all spaces', { tests: four }); - }); -} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/update_objects_spaces.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/update_objects_spaces.ts new file mode 100644 index 0000000000000..865d5eca22cbd --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/update_objects_spaces.ts @@ -0,0 +1,143 @@ +/* + * 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 '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; +import { updateObjectsSpacesTestSuiteFactory } from '../../common/suites/update_objects_spaces'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +/** + * Single-part test cases, which can be run in a single batch + * @param spaceId the space in which the test will take place (and the space the object will be removed from) + */ +const createSinglePartTestCases = (spaceId: string) => { + const spacesToAdd = ['some-space-id']; + const spacesToRemove = [spaceId]; + return { + objects: [ + { ...CASES.DEFAULT_ONLY, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SPACE_1_ONLY, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SPACE_2_ONLY, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_1, ...fail404(spaceId === SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_2, ...fail404(spaceId === SPACE_1_ID) }, + { ...CASES.SPACE_1_AND_SPACE_2, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + CASES.EACH_SPACE, + CASES.ALL_SPACES, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ], + spacesToAdd, + spacesToRemove, + }; +}; +/** + * Multi-part test cases, which have to be run sequentially + * These are non-exhaustive, but they check different permutations of saved objects and spaces to add + */ +const createMultiPartTestCases = () => { + const nonExistentSpace = 'does_not_exist'; // space that doesn't exist + const eachSpace = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + const group1 = [ + // first, add this object to each space and remove it from nonExistentSpace + // this will succeed even though the object already exists in the default space and it doesn't exist in nonExistentSpace + { objects: [CASES.DEFAULT_ONLY], spacesToAdd: eachSpace, spacesToRemove: [nonExistentSpace] }, + // second, add this object to nonExistentSpace and all spaces, and remove it from the default space + { + objects: [{ id: CASES.DEFAULT_ONLY.id, existingNamespaces: eachSpace }], + spacesToAdd: [nonExistentSpace, '*'], + spacesToRemove: [DEFAULT_SPACE_ID], + }, + // third, remove the object from all spaces + // the object is still accessible in the context of the default space because it currently exists in all spaces + { + objects: [ + { + id: CASES.DEFAULT_ONLY.id, + existingNamespaces: [SPACE_1_ID, SPACE_2_ID, nonExistentSpace, '*'], + }, + ], + spacesToAdd: [], + spacesToRemove: ['*'], + }, + // fourth, remove the object from space_1 + // this will fail because, even though the object still exists, it no longer exists in the context of the default space + { + objects: [ + { + id: CASES.DEFAULT_ONLY.id, + existingNamespaces: [SPACE_1_ID, SPACE_2_ID, nonExistentSpace], + ...fail404(), + }, + ], + spacesToAdd: [], + spacesToRemove: [SPACE_1_ID], + }, + ]; + const group2 = [ + // first, add this object to space_2 and remove it from space_1 + { + objects: [CASES.DEFAULT_AND_SPACE_1], + spacesToAdd: [SPACE_2_ID], + spacesToRemove: [SPACE_1_ID], + }, + // second, remove this object from the default space and space_2 + // since the object would no longer exist in any spaces, it will be deleted + { + objects: [ + { id: CASES.DEFAULT_AND_SPACE_1.id, existingNamespaces: [DEFAULT_SPACE_ID, SPACE_2_ID] }, + ], + spacesToAdd: [], + spacesToRemove: [DEFAULT_SPACE_ID, SPACE_1_ID], + }, + // fourth, add the object to the default space + // this will fail because the object no longer exists + { + objects: [{ id: CASES.DEFAULT_AND_SPACE_1.id, existingNamespaces: [], ...fail404() }], + spacesToAdd: [DEFAULT_SPACE_ID], + spacesToRemove: [], + }, + ]; + return [...group1, ...group2]; +}; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = updateObjectsSpacesTestSuiteFactory( + esArchiver, + supertest + ); + const createSinglePartTests = (spaceId: string) => { + const testCases = createSinglePartTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; + const createMultiPartTests = () => { + const testCases = createMultiPartTestCases(); + return createTestDefinitions(testCases, false); + }; + + describe('_update_objects_spaces', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createSinglePartTests(spaceId); + addTests(`targeting the ${spaceId} space`, { spaceId, tests }); + }); + const multiPartTests = createMultiPartTests(); + addTests('multi-part tests in the default space', { tests: multiPartTests }); + }); +}