diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md
index 39e14607d861f..2b43bafbede5c 100644
--- a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md
+++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md
@@ -19,7 +19,9 @@ export interface SavedObjectReferenceWithContext
| [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 | (Optional) Whether or not this object or reference is missing |
+| [originId?](./kibana-plugin-core-public.savedobjectreferencewithcontext.originid.md) | string | (Optional) The origin ID of the referenced object (if it has one) |
| [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\[\] | (Optional) The space(s) that legacy URL aliases matching this type/id exist in |
+| [spacesWithMatchingOrigins?](./kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingorigins.md) | string\[\] | (Optional) The space(s) that objects matching this origin exist in (including this one) |
| [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.originid.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.originid.md
new file mode 100644
index 0000000000000..418041ea5df60
--- /dev/null
+++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.originid.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) > [originId](./kibana-plugin-core-public.savedobjectreferencewithcontext.originid.md)
+
+## SavedObjectReferenceWithContext.originId property
+
+The origin ID of the referenced object (if it has one)
+
+Signature:
+
+```typescript
+originId?: string;
+```
diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingorigins.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingorigins.md
new file mode 100644
index 0000000000000..88a7ebb5f2234
--- /dev/null
+++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingorigins.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) > [spacesWithMatchingOrigins](./kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingorigins.md)
+
+## SavedObjectReferenceWithContext.spacesWithMatchingOrigins property
+
+The space(s) that objects matching this origin exist in (including this one)
+
+Signature:
+
+```typescript
+spacesWithMatchingOrigins?: string[];
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md
index 8cdfbb4fde480..79dd7a40019ec 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md
@@ -19,7 +19,9 @@ export interface SavedObjectReferenceWithContext
| [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 | (Optional) Whether or not this object or reference is missing |
+| [originId?](./kibana-plugin-core-server.savedobjectreferencewithcontext.originid.md) | string | (Optional) The origin ID of the referenced object (if it has one) |
| [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\[\] | (Optional) The space(s) that legacy URL aliases matching this type/id exist in |
+| [spacesWithMatchingOrigins?](./kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingorigins.md) | string\[\] | (Optional) The space(s) that objects matching this origin exist in (including this one) |
| [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.originid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.originid.md
new file mode 100644
index 0000000000000..47cac3f423647
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.originid.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) > [originId](./kibana-plugin-core-server.savedobjectreferencewithcontext.originid.md)
+
+## SavedObjectReferenceWithContext.originId property
+
+The origin ID of the referenced object (if it has one)
+
+Signature:
+
+```typescript
+originId?: string;
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingorigins.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingorigins.md
new file mode 100644
index 0000000000000..3fedce753c034
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingorigins.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) > [spacesWithMatchingOrigins](./kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingorigins.md)
+
+## SavedObjectReferenceWithContext.spacesWithMatchingOrigins property
+
+The space(s) that objects matching this origin exist in (including this one)
+
+Signature:
+
+```typescript
+spacesWithMatchingOrigins?: string[];
+```
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index ecd326190d6c1..f805f24cb05e8 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -1089,8 +1089,10 @@ export interface SavedObjectReferenceWithContext {
name: string;
}>;
isMissing?: boolean;
+ originId?: string;
spaces: string[];
spacesWithMatchingAliases?: string[];
+ spacesWithMatchingOrigins?: string[];
type: string;
}
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
index 728f3b847b631..5476f99c3b37d 100644
--- 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
@@ -7,6 +7,7 @@
*/
import type { findLegacyUrlAliases } from './legacy_url_aliases';
+import type { findSharedOriginObjects } from './find_shared_origin_objects';
import type * as InternalUtils from './internal_utils';
export const mockFindLegacyUrlAliases = jest.fn() as jest.MockedFunction<
@@ -17,6 +18,14 @@ jest.mock('./legacy_url_aliases', () => {
return { findLegacyUrlAliases: mockFindLegacyUrlAliases };
});
+export const mockFindSharedOriginObjects = jest.fn() as jest.MockedFunction<
+ typeof findSharedOriginObjects
+>;
+
+jest.mock('./find_shared_origin_objects', () => {
+ return { findSharedOriginObjects: mockFindSharedOriginObjects };
+});
+
export const mockRawDocExistsInNamespace = jest.fn() as jest.MockedFunction<
typeof InternalUtils['rawDocExistsInNamespace']
>;
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
index 202b5ca4386c9..bac745995ce07 100644
--- 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
@@ -8,6 +8,7 @@
import {
mockFindLegacyUrlAliases,
+ mockFindSharedOriginObjects,
mockRawDocExistsInNamespace,
} from './collect_multi_namespace_references.test.mock';
@@ -15,7 +16,7 @@ import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks';
import { typeRegistryMock } from '../../saved_objects_type_registry.mock';
import { SavedObjectsSerializer } from '../../serialization';
import {
- ALIAS_SEARCH_PER_PAGE,
+ ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE,
CollectMultiNamespaceReferencesParams,
SavedObjectsCollectMultiNamespaceReferencesObject,
SavedObjectsCollectMultiNamespaceReferencesOptions,
@@ -35,6 +36,8 @@ const MULTI_NAMESPACE_HIDDEN_OBJ_TYPE = 'type-d';
beforeEach(() => {
mockFindLegacyUrlAliases.mockReset();
mockFindLegacyUrlAliases.mockResolvedValue(new Map()); // return an empty map by default
+ mockFindSharedOriginObjects.mockReset();
+ mockFindSharedOriginObjects.mockResolvedValue(new Map()); // return an empty map by default
mockRawDocExistsInNamespace.mockReset();
mockRawDocExistsInNamespace.mockReturnValue(true); // return true by default
});
@@ -82,6 +85,7 @@ describe('collectMultiNamespaceReferences', () => {
function mockMgetResults(
...results: Array<{
found: boolean;
+ originId?: string;
references?: Array<{ type: string; id: string }>;
}>
) {
@@ -95,6 +99,7 @@ describe('collectMultiNamespaceReferences', () => {
_index: 'doesnt-matter',
_source: {
namespaces: SPACES,
+ originId: x.originId,
references,
},
...VERSION_PROPS,
@@ -321,7 +326,7 @@ describe('collectMultiNamespaceReferences', () => {
expect(mockFindLegacyUrlAliases).toHaveBeenCalledWith(
expect.anything(),
[obj1, obj2, obj3],
- ALIAS_SEARCH_PER_PAGE
+ ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE
);
expect(result.objects).toEqual([
{
@@ -346,7 +351,7 @@ describe('collectMultiNamespaceReferences', () => {
expect(mockFindLegacyUrlAliases).toHaveBeenCalledWith(
expect.anything(),
[obj1],
- ALIAS_SEARCH_PER_PAGE
+ ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE
);
});
@@ -363,4 +368,81 @@ describe('collectMultiNamespaceReferences', () => {
);
});
});
+
+ describe('shared origins', () => {
+ it('uses findSharedOriginObjects to search for objects with shared origins', async () => {
+ const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' };
+ const obj2 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-x', originId: 'id-2' };
+ const obj3 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-3' };
+ const params = setup([obj1, obj2], {});
+ mockMgetResults(
+ // results for obj1 and obj2
+ { found: true, references: [obj3] },
+ { found: true, originId: obj2.originId, references: [] }
+ );
+ mockMgetResults({ found: true, references: [] }); // results for obj3
+ mockFindSharedOriginObjects.mockResolvedValue(
+ new Map([
+ [`${obj1.type}:${obj1.id}`, new Set(['space-1'])],
+ [`${obj2.type}:${obj2.originId}`, new Set(['*'])],
+ [`${obj3.type}:${obj3.id}`, new Set(['space-1', 'space-2'])],
+ ])
+ );
+
+ 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(mockFindSharedOriginObjects).toHaveBeenCalledTimes(1);
+ expect(mockFindSharedOriginObjects).toHaveBeenCalledWith(
+ expect.anything(),
+ [
+ { type: obj1.type, origin: obj1.id },
+ { type: obj2.type, origin: obj2.originId }, // If the found object has an `originId`, that is used instead of the object's `id`.
+ { type: obj3.type, origin: obj3.id },
+ ],
+ ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE
+ );
+ expect(result.objects).toEqual([
+ // Note: in a realistic scenario, `spacesWithMatchingOrigins` would be a superset of `spaces`. But for the purposes of this unit
+ // test, it doesn't matter if they are different.
+ { ...obj1, spaces: SPACES, inboundReferences: [], spacesWithMatchingOrigins: ['space-1'] },
+ { ...obj2, spaces: SPACES, inboundReferences: [], spacesWithMatchingOrigins: ['*'] },
+ {
+ ...obj3,
+ spaces: SPACES,
+ inboundReferences: [{ ...obj1, name: 'ref-name' }],
+ spacesWithMatchingOrigins: ['space-1', 'space-2'],
+ },
+ ]);
+ });
+
+ it('omits 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(mockFindSharedOriginObjects).toHaveBeenCalledTimes(1);
+ expect(mockFindSharedOriginObjects).toHaveBeenCalledWith(
+ expect.anything(),
+ [{ type: obj1.type, origin: obj1.id }],
+ ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE
+ );
+ });
+
+ it('handles findSharedOriginObjects errors', async () => {
+ const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' };
+ const params = setup([obj1]);
+ mockMgetResults({ found: true }); // results for obj1
+ mockFindSharedOriginObjects.mockRejectedValue(
+ new Error('Failed to retrieve shared origin objects: Oh no!')
+ );
+
+ await expect(() => collectMultiNamespaceReferences(params)).rejects.toThrow(
+ 'Failed to retrieve shared origin objects: Oh no!'
+ );
+ });
+ });
});
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
index a404f2e9475b7..a6336a89ac6fe 100644
--- 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
@@ -21,6 +21,7 @@ import {
} from './internal_utils';
import type { CreatePointInTimeFinderFn } from './point_in_time_finder';
import type { RepositoryEsClient } from './repository_es_client';
+import { findSharedOriginObjects } from './find_shared_origin_objects';
/**
* When we collect an object's outbound references, we will only go a maximum of this many levels deep before we throw an error.
@@ -28,13 +29,13 @@ import type { RepositoryEsClient } from './repository_es_client';
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.
+ * How many aliases or objects with shared origins 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.
*
* @internal
*/
-export const ALIAS_SEARCH_PER_PAGE = 100;
+export const ALIAS_OR_SHARED_ORIGIN_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
@@ -71,6 +72,8 @@ export interface SavedObjectReferenceWithContext {
type: string;
/** The ID of the referenced object */
id: string;
+ /** The origin ID of the referenced object (if it has one) */
+ originId?: string;
/** The space(s) that the referenced object exists in */
spaces: string[];
/**
@@ -89,6 +92,8 @@ export interface SavedObjectReferenceWithContext {
isMissing?: boolean;
/** The space(s) that legacy URL aliases matching this type/id exist in */
spacesWithMatchingAliases?: string[];
+ /** The space(s) that objects matching this origin exist in (including this one) */
+ spacesWithMatchingOrigins?: string[];
}
/**
@@ -140,8 +145,16 @@ export async function collectMultiNamespaceReferences(
});
const { type, id } = parseObjectKey(referenceKey);
const object = objectMap.get(referenceKey);
+ const originId = object?.originId;
const spaces = object?.namespaces ?? [];
- return { type, id, spaces, inboundReferences, ...(object === null && { isMissing: true }) };
+ return {
+ type,
+ id,
+ originId,
+ spaces,
+ inboundReferences,
+ ...(object === null && { isMissing: true }),
+ };
});
const objectsToFindAliasesFor = objectsWithContext
@@ -150,13 +163,22 @@ export async function collectMultiNamespaceReferences(
const aliasesMap = await findLegacyUrlAliases(
createPointInTimeFinder,
objectsToFindAliasesFor,
- ALIAS_SEARCH_PER_PAGE
+ ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE
+ );
+ const objectOriginsToSearchFor = objectsWithContext
+ .filter(({ spaces }) => spaces.length !== 0)
+ .map(({ type, id, originId }) => ({ type, origin: originId || id }));
+ const originsMap = await findSharedOriginObjects(
+ createPointInTimeFinder,
+ objectOriginsToSearchFor,
+ ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE
);
const results = objectsWithContext.map((obj) => {
- const key = getObjectKey(obj);
- const val = aliasesMap.get(key);
- const spacesWithMatchingAliases = val && Array.from(val);
- return { ...obj, spacesWithMatchingAliases };
+ const aliasesVal = aliasesMap.get(getObjectKey(obj));
+ const spacesWithMatchingAliases = aliasesVal && Array.from(aliasesVal).sort();
+ const originsVal = originsMap.get(getObjectKey({ type: obj.type, id: obj.originId || obj.id }));
+ const spacesWithMatchingOrigins = originsVal && Array.from(originsVal).sort();
+ return { ...obj, spacesWithMatchingAliases, spacesWithMatchingOrigins };
});
return {
diff --git a/src/core/server/saved_objects/service/lib/find_shared_origin_objects.test.ts b/src/core/server/saved_objects/service/lib/find_shared_origin_objects.test.ts
new file mode 100644
index 0000000000000..c8e0796dea18e
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/find_shared_origin_objects.test.ts
@@ -0,0 +1,153 @@
+/*
+ * 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 { DeeplyMockedKeys } from '@kbn/utility-types/jest';
+
+import type { CreatePointInTimeFinderFn, PointInTimeFinder } from './point_in_time_finder';
+import { savedObjectsPointInTimeFinderMock } from './point_in_time_finder.mock';
+import type { ISavedObjectsRepository } from './repository';
+import { savedObjectsRepositoryMock } from './repository.mock';
+import { findSharedOriginObjects } from './find_shared_origin_objects';
+
+interface MockFindResultParams {
+ type: string;
+ id: string;
+ originId?: string;
+ namespaces: string[];
+}
+
+describe('findSharedOriginObjects', () => {
+ let savedObjectsMock: jest.Mocked;
+ let pointInTimeFinder: DeeplyMockedKeys;
+ let createPointInTimeFinder: jest.MockedFunction;
+
+ beforeEach(() => {
+ 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,
+ });
+ pointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ savedObjectsMock })(); // PIT finder mock uses the actual implementation, but it doesn't need to be created with real params because the SOR is mocked too
+ createPointInTimeFinder = jest.fn().mockReturnValue(pointInTimeFinder);
+ });
+
+ function mockFindResults(...results: MockFindResultParams[]) {
+ savedObjectsMock.find.mockResolvedValueOnce({
+ pit_id: 'foo',
+ saved_objects: results.map(({ type, id, originId, namespaces }) => ({
+ type,
+ id,
+ namespaces,
+ ...(originId && { originId }),
+ 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,
+ });
+ }
+
+ const obj1 = { type: 'type-1', origin: 'id-1' };
+ const obj2 = { type: 'type-2', origin: 'id-2' };
+ const obj3 = { type: 'type-3', origin: 'id-3' };
+ const obj4 = { type: 'type-4', origin: 'id-4' };
+
+ it('uses the PointInTimeFinder to search for legacy URL aliases', async () => {
+ mockFindResults(
+ { type: 'type-1', id: 'id-1', namespaces: ['space-a', 'space-b'] },
+ { type: 'type-1', id: 'id-x', originId: 'id-1', namespaces: ['space-b', 'space-c'] },
+ { type: 'type-2', id: 'id-2', namespaces: ['*', 'space-d'] },
+ { type: 'type-2', id: 'id-y', originId: 'id-2', namespaces: ['space-e'] },
+ { type: 'type-3', id: 'id-3', namespaces: ['f'] },
+ { type: 'type-3', id: 'id-z', originId: 'id-3', namespaces: ['*', 'space-g'] }
+ // no results matching obj4
+ );
+
+ const objects = [obj1, obj2, obj3, obj4];
+ const result = await findSharedOriginObjects(createPointInTimeFinder, objects);
+ expect(createPointInTimeFinder).toHaveBeenCalledTimes(1);
+ expect(createPointInTimeFinder).toHaveBeenCalledWith(
+ expect.objectContaining({ type: ['type-1', 'type-2', 'type-3', 'type-4'] }) // filter assertions are below
+ );
+ const kueryFilterArgs = createPointInTimeFinder.mock.calls[0][0].filter.arguments;
+ expect(kueryFilterArgs).toHaveLength(8); // 2 for each object
+ [obj1, obj2, obj3].forEach(({ type, origin }, i) => {
+ expect(kueryFilterArgs[i * 2].arguments).toEqual(
+ expect.arrayContaining([
+ { type: 'literal', value: `${type}.id` },
+ { type: 'literal', value: `${type}:${origin}` },
+ ])
+ );
+ expect(kueryFilterArgs[i * 2 + 1].arguments).toEqual(
+ expect.arrayContaining([
+ { type: 'literal', value: `${type}.originId` },
+ { type: 'literal', value: origin },
+ ])
+ );
+ });
+ expect(pointInTimeFinder.find).toHaveBeenCalledTimes(1);
+ expect(pointInTimeFinder.close).toHaveBeenCalledTimes(2);
+ expect(result).toEqual(
+ // This contains multiple assertions about the response:
+ // 1. A match's `id` is ignored if it has a defined `originId`
+ // 2. The `namespaces` from different matches are combined into a single set, and duplicate space IDs are filtered out
+ // 3. If the first match's `namespaces` array contains '*', all other space IDs are filtered out
+ // 4. If the last match's `namespaces` array contains '*', all other space IDs are filtered out
+ // 5. Objects that have no matches will not have an entry in the result map
+ new Map([
+ ['type-1:id-1', new Set(['space-a', 'space-b', 'space-c'])],
+ ['type-2:id-2', new Set(['*'])],
+ ['type-3:id-3', new Set(['*'])],
+ // the result map does not contain keys for obj4 because we did not find any matches for that object
+ ])
+ );
+ });
+
+ it('allows perPage to be set', async () => {
+ const objects = [obj1, obj2, obj3];
+ await findSharedOriginObjects(createPointInTimeFinder, objects, 999);
+ expect(createPointInTimeFinder).toHaveBeenCalledTimes(1);
+ expect(createPointInTimeFinder).toHaveBeenCalledWith(expect.objectContaining({ perPage: 999 }));
+ });
+
+ it('does not create a PointInTimeFinder if no objects are passed in', async () => {
+ await findSharedOriginObjects(createPointInTimeFinder, []);
+ expect(createPointInTimeFinder).not.toHaveBeenCalled();
+ });
+
+ it('handles PointInTimeFinder.find errors', async () => {
+ savedObjectsMock.find.mockRejectedValue(new Error('Oh no!'));
+
+ const objects = [obj1, obj2, obj3];
+ await expect(() => findSharedOriginObjects(createPointInTimeFinder, objects)).rejects.toThrow(
+ 'Failed to retrieve shared origin objects: 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 () => {
+ pointInTimeFinder.close.mockRejectedValue(new Error('Oh no!'));
+
+ const objects = [obj1, obj2, obj3];
+ await expect(() => findSharedOriginObjects(createPointInTimeFinder, objects)).rejects.toThrow(
+ 'Failed to retrieve shared origin objects: 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/find_shared_origin_objects.ts b/src/core/server/saved_objects/service/lib/find_shared_origin_objects.ts
new file mode 100644
index 0000000000000..229e0c6f90a66
--- /dev/null
+++ b/src/core/server/saved_objects/service/lib/find_shared_origin_objects.ts
@@ -0,0 +1,92 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import * as esKuery from '@kbn/es-query';
+import { getObjectKey } from './internal_utils';
+import type { CreatePointInTimeFinderFn } from './point_in_time_finder';
+import { ALL_NAMESPACES_STRING } from './utils';
+
+interface ObjectOrigin {
+ /** The object's type. */
+ type: string;
+ /** The object's origin is its `originId` field, or its `id` field if that is unavailable. */
+ origin: string;
+}
+
+/**
+ * Fetches all objects with a shared origin, returning a map of the matching aliases and what space(s) they exist in.
+ *
+ * @internal
+ */
+export async function findSharedOriginObjects(
+ createPointInTimeFinder: CreatePointInTimeFinderFn,
+ objects: ObjectOrigin[],
+ perPage?: number
+) {
+ if (!objects.length) {
+ return new Map>();
+ }
+
+ const uniqueObjectTypes = objects.reduce((acc, { type }) => acc.add(type), new Set());
+ const filter = createAliasKueryFilter(objects);
+ const finder = createPointInTimeFinder({
+ type: [...uniqueObjectTypes],
+ perPage,
+ filter,
+ fields: ['not-a-field'], // Specify a non-existent field to avoid fetching all type-level fields (we only care about root-level fields)
+ namespaces: [ALL_NAMESPACES_STRING], // We need to search across all spaces to have accurate results
+ });
+ // NOTE: this objectsMap is only used internally (not in an API that is documented for public consumption), and it contains the minimal
+ // amount of information to satisfy our UI needs today. We will need to change this in the future when we implement merging in #130311.
+ const objectsMap = new Map>();
+ let error: Error | undefined;
+ try {
+ for await (const { saved_objects: savedObjects } of finder.find()) {
+ for (const savedObject of savedObjects) {
+ const { type, id, originId, namespaces = [] } = savedObject;
+ const key = getObjectKey({ type, id: originId || id });
+ const val = objectsMap.get(key) ?? new Set();
+ const filteredNamespaces =
+ namespaces.includes(ALL_NAMESPACES_STRING) || val.has(ALL_NAMESPACES_STRING)
+ ? [ALL_NAMESPACES_STRING]
+ : [...val, ...namespaces];
+ objectsMap.set(key, new Set([...filteredNamespaces]));
+ }
+ }
+ } catch (e) {
+ error = e;
+ }
+
+ try {
+ await finder.close();
+ } catch (e) {
+ if (!error) {
+ error = e;
+ }
+ }
+
+ if (error) {
+ throw new Error(`Failed to retrieve shared origin objects: ${error.message}`);
+ }
+ return objectsMap;
+}
+
+function createAliasKueryFilter(objects: Array<{ type: string; origin: string }>) {
+ const { buildNode } = esKuery.nodeTypes.function;
+ // Note: these nodes include '.attributes' for type-level fields because these are eventually passed to `validateConvertFilterToKueryNode`, which requires it
+ const kueryNodes = objects
+ .reduce((acc, { type, origin }) => {
+ // Escape Kuery values to prevent parsing errors and unintended behavior (object types/IDs can contain KQL special characters/operators)
+ const match1 = buildNode('is', `${type}.id`, esKuery.escapeKuery(`${type}:${origin}`)); // here we are looking for the raw document `_id` field, which has a `type:` prefix
+ const match2 = buildNode('is', `${type}.originId`, esKuery.escapeKuery(origin)); // here we are looking for the saved object's `originId` field, which does not have a `type:` prefix
+ acc.push([match1, match2]);
+ return acc;
+ }, [])
+ .flat();
+ return buildNode('or', kueryNodes);
+}
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 0a48e4dc6380a..cec9eacdce5dc 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -2052,8 +2052,10 @@ export interface SavedObjectReferenceWithContext {
name: string;
}>;
isMissing?: boolean;
+ originId?: string;
spaces: string[];
spacesWithMatchingAliases?: string[];
+ spacesWithMatchingOrigins?: string[];
type: string;
}
diff --git a/src/plugins/saved_objects_management/public/services/actions/copy_saved_objects_to_space_action.tsx b/src/plugins/saved_objects_management/public/services/actions/copy_saved_objects_to_space_action.tsx
index 1ba5d4a3f48b4..3d51f6e52a728 100644
--- a/src/plugins/saved_objects_management/public/services/actions/copy_saved_objects_to_space_action.tsx
+++ b/src/plugins/saved_objects_management/public/services/actions/copy_saved_objects_to_space_action.tsx
@@ -30,7 +30,7 @@ export class CopyToSpaceSavedObjectsManagementAction extends SavedObjectsManagem
public euiAction = {
name: i18n.translate('savedObjectsManagement.copyToSpace.actionTitle', {
- defaultMessage: 'Copy to space',
+ defaultMessage: 'Copy to spaces',
}),
description: i18n.translate('savedObjectsManagement.copyToSpace.actionDescription', {
defaultMessage: 'Make a copy of this saved object in one or more spaces',
diff --git a/src/plugins/saved_objects_management/public/services/actions/share_saved_objects_to_space_action.tsx b/src/plugins/saved_objects_management/public/services/actions/share_saved_objects_to_space_action.tsx
index 00b99e9327a58..cec28e07fdaf3 100644
--- a/src/plugins/saved_objects_management/public/services/actions/share_saved_objects_to_space_action.tsx
+++ b/src/plugins/saved_objects_management/public/services/actions/share_saved_objects_to_space_action.tsx
@@ -30,10 +30,10 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage
public euiAction = {
name: i18n.translate('savedObjectsManagement.shareToSpace.actionTitle', {
- defaultMessage: 'Assign spaces',
+ defaultMessage: 'Share to spaces',
}),
description: i18n.translate('savedObjectsManagement.shareToSpace.actionDescription', {
- defaultMessage: 'Change the spaces this object is assigned to',
+ defaultMessage: 'Share this object to one or more spaces',
}),
icon: 'share',
type: 'icon',
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 45a09f9a38967..0f96beb4b5eaf 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
@@ -1457,6 +1457,8 @@ describe('#collectMultiNamespaceReferences', () => {
const reqObj1 = { type: 'a', id: '1' };
const reqObj2 = { type: 'b', id: '2' };
const spaces = [spaceX, spaceY, spaceZ];
+ const spacesWithMatchingAliases = [spaceX, spaceY, spaceZ];
+ const spacesWithMatchingOrigins = [spaceX, spaceY, spaceZ];
// Actual object graph:
// ─► obj1 (a:1) ─┬─► obj3 (c:3) ───► obj5 (c:5) ─► obj8 (c:8) ─┐
@@ -1471,9 +1473,24 @@ describe('#collectMultiNamespaceReferences', () => {
// │ └───────────────────────────────────┘
// └─► obj4 (d:4)
// ─► obj2 (b:2)
- const obj1 = { ...reqObj1, spaces, inboundReferences: [] };
+ const obj1 = {
+ ...reqObj1,
+ spaces,
+ inboundReferences: [],
+ // We include spacesWithMatchingAliases and spacesWithMatchingOrigins on this object of type 'a' (which the user is authorized to access globally) to assert that they are not redacted
+ spacesWithMatchingAliases,
+ spacesWithMatchingOrigins,
+ };
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 obj3 = {
+ type: 'c',
+ id: '3',
+ spaces,
+ ...getInboundRefsFrom(obj1),
+ // We include spacesWithMatchingAliases and spacesWithMatchingOrigins on this object of type 'c' (which the user is partially authorized for) to assert that they are redacted
+ spacesWithMatchingAliases,
+ spacesWithMatchingOrigins,
+ };
const obj4 = { type: 'd', id: '4', spaces, ...getInboundRefsFrom(obj1) };
const obj5 = {
type: 'c',
@@ -1510,9 +1527,14 @@ describe('#collectMultiNamespaceReferences', () => {
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
+ obj1, // obj1's spaces, spacesWithMatchingAliases, and spacesWithMatchingOrigins arrays are not redacted because the user is globally authorized to access it
obj2, // obj2 has an empty spaces array (see above)
- { ...obj3, spaces: [spaceX, '?', '?'] },
+ {
+ ...obj3,
+ spaces: [spaceX, '?', '?'],
+ spacesWithMatchingAliases: [spaceX, '?', '?'],
+ spacesWithMatchingOrigins: [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)
@@ -1567,9 +1589,14 @@ describe('#collectMultiNamespaceReferences', () => {
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
+ obj1, // obj1's spaces, spacesWithMatchingAliases, and spacesWithMatchingOrigins arrays are not redacted because the user is globally authorized to access it
obj2, // obj2 has an empty spaces array (see above)
- { ...obj3, spaces: [spaceX, spaceY, '?'] },
+ {
+ ...obj3,
+ spaces: [spaceX, spaceY, '?'],
+ spacesWithMatchingAliases: [spaceX, spaceY, '?'],
+ spacesWithMatchingOrigins: [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)
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 f45563d20946e..6b4c3bf4e799c 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
@@ -655,8 +655,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
const uniqueTypes = this.getUniqueObjectTypes(response.objects);
const uniqueSpaces = this.getUniqueSpaces(
currentSpaceId,
- ...response.objects.flatMap(({ spaces, spacesWithMatchingAliases = [] }) =>
- spaces.concat(spacesWithMatchingAliases)
+ ...response.objects.flatMap(
+ ({ spaces, spacesWithMatchingAliases = [], spacesWithMatchingOrigins = [] }) => [
+ ...spaces,
+ ...spacesWithMatchingAliases,
+ ...spacesWithMatchingOrigins,
+ ]
)
);
@@ -770,7 +774,14 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
}
const filteredAndRedactedObjects = [...filteredObjectsMap.values()].map((obj) => {
- const { type, id, spaces, spacesWithMatchingAliases, inboundReferences } = obj;
+ const {
+ type,
+ id,
+ spaces,
+ spacesWithMatchingAliases,
+ spacesWithMatchingOrigins,
+ 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) {
@@ -783,12 +794,18 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
const redactedSpacesWithMatchingAliases =
spacesWithMatchingAliases &&
getRedactedSpaces(type, 'bulk_get', typeActionMap, spacesWithMatchingAliases);
+ const redactedSpacesWithMatchingOrigins =
+ spacesWithMatchingOrigins &&
+ getRedactedSpaces(type, 'bulk_get', typeActionMap, spacesWithMatchingOrigins);
return {
...obj,
spaces: redactedSpaces,
...(redactedSpacesWithMatchingAliases && {
spacesWithMatchingAliases: redactedSpacesWithMatchingAliases,
}),
+ ...(redactedSpacesWithMatchingOrigins && {
+ spacesWithMatchingOrigins: redactedSpacesWithMatchingOrigins,
+ }),
inboundReferences: redactedInboundReferences,
};
});
diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_internal.tsx
index ab63ceb7d6b29..b4d3ec634b8f3 100644
--- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_internal.tsx
+++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/copy_to_space_flyout_internal.tsx
@@ -259,7 +259,7 @@ export const CopyToSpaceFlyoutInternal = (props: CopyToSpaceFlyoutProps) => {
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx
index 849a8a7805185..0be5795cb9454 100644
--- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx
@@ -43,6 +43,7 @@ interface Props {
onChange: (selectedSpaceIds: string[]) => void;
enableCreateNewSpaceLink: boolean;
enableSpaceAgnosticBehavior: boolean;
+ prohibitedSpaces: Set;
}
type SpaceOption = EuiSelectableOption & { ['data-space-id']: string };
@@ -73,6 +74,18 @@ const APPEND_CANNOT_DESELECT = (
type="iInCircle"
/>
);
+const APPEND_PROHIBITED = (
+
+);
const APPEND_FEATURE_IS_DISABLED = (
{
- const { spaces, shareOptions, onChange, enableCreateNewSpaceLink, enableSpaceAgnosticBehavior } =
- props;
+ const {
+ spaces,
+ shareOptions,
+ onChange,
+ enableCreateNewSpaceLink,
+ enableSpaceAgnosticBehavior,
+ prohibitedSpaces,
+ } = props;
const { services } = useSpaces();
const { application, docLinks } = services;
const { selectedSpaceIds, initiallySelectedSpaceIds } = shareOptions;
@@ -108,7 +127,8 @@ export const SelectableSpacesControl = (props: Props) => {
space,
activeSpaceId,
checked,
- isGlobalControlChecked
+ isGlobalControlChecked,
+ prohibitedSpaces
);
return {
label: space.name,
@@ -246,7 +266,8 @@ function getAdditionalProps(
space: SpacesDataEntry,
activeSpaceId: string | false,
checked: boolean,
- isGlobalControlChecked: boolean
+ isGlobalControlChecked: boolean,
+ prohibitedSpaces: Set
) {
if (space.id === activeSpaceId) {
return {
@@ -267,6 +288,18 @@ function getAdditionalProps(
disabled: true,
};
}
+ if (prohibitedSpaces.has(space.id) || prohibitedSpaces.has(ALL_SPACES_ID)) {
+ return {
+ append: (
+ <>
+ {APPEND_PROHIBITED}
+ {space.isFeatureDisabled ? APPEND_FEATURE_IS_DISABLED : null}
+ >
+ ),
+ ...(space.isFeatureDisabled && { isAvatarDisabled: true }),
+ disabled: true,
+ };
+ }
if (space.isFeatureDisabled) {
return {
append: APPEND_FEATURE_IS_DISABLED,
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx
index 0e8992ea6a3df..319b8a0c98a9c 100644
--- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx
@@ -16,6 +16,7 @@ import {
EuiSpacer,
EuiText,
} from '@elastic/eui';
+import type { ReactNode } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
@@ -35,6 +36,7 @@ interface Props {
onChange: (selectedSpaceIds: string[]) => void;
enableCreateNewSpaceLink: boolean;
enableSpaceAgnosticBehavior: boolean;
+ prohibitedSpaces: Set;
}
const buttonGroupLegend = i18n.translate(
@@ -54,9 +56,30 @@ const shareToExplicitSpacesButtonLabel = i18n.translate(
{ defaultMessage: 'Select spaces' }
);
-const cannotChangeTooltip = i18n.translate(
- 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotChangeTooltip',
- { defaultMessage: 'You need additional privileges to change this option.' }
+const CANNOT_CHANGE_TOOLTIP = (
+
+);
+
+const ALL_SPACES_PROHIBITED_TOOLTIP = (
+
);
export const ShareModeControl = (props: Props) => {
@@ -68,6 +91,7 @@ export const ShareModeControl = (props: Props) => {
onChange,
enableCreateNewSpaceLink,
enableSpaceAgnosticBehavior,
+ prohibitedSpaces,
} = props;
const { services } = useSpaces();
const { docLinks } = services;
@@ -120,6 +144,14 @@ export const ShareModeControl = (props: Props) => {
);
};
+ const isGlobalControlChangeProhibited = prohibitedSpaces.size > 0 && !isGlobalControlChecked;
+ let globalControlTooltip: ReactNode = null;
+ if (!canShareToAllSpaces) {
+ globalControlTooltip = CANNOT_CHANGE_TOOLTIP;
+ } else if (isGlobalControlChangeProhibited) {
+ globalControlTooltip = ALL_SPACES_PROHIBITED_TOOLTIP;
+ }
+
return (
<>
{getPrivilegeWarning()}
@@ -141,7 +173,7 @@ export const ShareModeControl = (props: Props) => {
legend={buttonGroupLegend}
color="success"
isFullWidth={true}
- isDisabled={!canShareToAllSpaces}
+ isDisabled={!canShareToAllSpaces || isGlobalControlChangeProhibited}
/>
@@ -173,11 +205,7 @@ export const ShareModeControl = (props: Props) => {
)}
- {!canShareToAllSpaces && (
-
-
-
- )}
+ {globalControlTooltip && {globalControlTooltip}}
@@ -190,6 +218,7 @@ export const ShareModeControl = (props: Props) => {
onChange={onChange}
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
+ prohibitedSpaces={prohibitedSpaces}
/>
>
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 78e64e035bc45..157b684e2c993 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
@@ -43,6 +43,14 @@ import { RelativesFooter } from './relatives_footer';
import { ShareToSpaceForm } from './share_to_space_form';
import type { InternalLegacyUrlAliasTarget } from './types';
+interface SpacesState {
+ isLoading: boolean;
+ spaces: SpacesDataEntry[];
+ referenceGraph: SavedObjectReferenceWithContext[];
+ aliasTargets: InternalLegacyUrlAliasTarget[];
+ prohibitedSpaces: Set; // Any spaces that we cannot share this object to because another object with a matching origin exists there
+}
+
// No need to wrap LazyCopyToSpaceFlyout in an error boundary, because the ShareToSpaceFlyoutInternal component itself is only ever used in
// a lazy-loaded fashion with an error boundary.
const LazyCopyToSpaceFlyout = lazy(() =>
@@ -143,7 +151,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
const {
flyoutIcon,
flyoutTitle = i18n.translate('xpack.spaces.shareToSpace.flyoutTitle', {
- defaultMessage: 'Assign {objectNoun} to spaces',
+ defaultMessage: 'Share {objectNoun} to spaces',
values: { objectNoun: savedObjectTarget.noun },
}),
enableCreateCopyCallout = false,
@@ -166,12 +174,14 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false);
const [showMakeCopy, setShowMakeCopy] = useState(false);
- const [{ isLoading, spaces, referenceGraph, aliasTargets }, setSpacesState] = useState<{
- isLoading: boolean;
- spaces: SpacesDataEntry[];
- referenceGraph: SavedObjectReferenceWithContext[];
- aliasTargets: InternalLegacyUrlAliasTarget[];
- }>({ isLoading: true, spaces: [], referenceGraph: [], aliasTargets: [] });
+ const [{ isLoading, spaces, referenceGraph, aliasTargets, prohibitedSpaces }, setSpacesState] =
+ useState({
+ isLoading: true,
+ spaces: [],
+ referenceGraph: [],
+ aliasTargets: [],
+ prohibitedSpaces: new Set(),
+ });
useEffect(() => {
const { type, id } = savedObjectTarget;
const getShareableReferences = spacesManager.getShareableReferences([{ type, id }]);
@@ -194,7 +204,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
aliasTargets: shareableReferences.objects.reduce(
(acc, x) => {
for (const space of x.spacesWithMatchingAliases ?? []) {
- if (space !== '?') {
+ if (space !== UNKNOWN_SPACE) {
const spaceExists = spacesData.spacesMap.has(space);
// If the user does not have privileges to view all spaces, they will be redacted; we cannot attempt to disable aliases for redacted spaces.
acc.push({ targetSpace: space, targetType: x.type, sourceId: x.id, spaceExists });
@@ -204,6 +214,20 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
},
[]
),
+ prohibitedSpaces: shareableReferences.objects.reduce((acc, x) => {
+ // Whenever we detect that a space contains an object with a matching origin, *and* the list of currently selected spaces does
+ // not include it, then it is prohibited. That means the user cannot share the object to those spaces.
+ for (const space of x.spacesWithMatchingOrigins ?? []) {
+ if (
+ space !== UNKNOWN_SPACE &&
+ !selectedSpaceIds.includes(space) &&
+ space !== activeSpaceId
+ ) {
+ acc.add(space);
+ }
+ }
+ return acc;
+ }, new Set()),
});
})
.catch((e) => {
@@ -329,6 +353,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
makeCopy={() => setShowMakeCopy(true)}
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
+ prohibitedSpaces={prohibitedSpaces}
/>
);
}
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx
index 4d39a590d8603..e5391e3c87143 100644
--- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx
@@ -26,6 +26,7 @@ interface Props {
makeCopy: () => void;
enableCreateNewSpaceLink: boolean;
enableSpaceAgnosticBehavior: boolean;
+ prohibitedSpaces: Set;
}
export const ShareToSpaceForm = (props: Props) => {
@@ -39,6 +40,7 @@ export const ShareToSpaceForm = (props: Props) => {
makeCopy,
enableCreateNewSpaceLink,
enableSpaceAgnosticBehavior,
+ prohibitedSpaces,
} = props;
const setSelectedSpaceIds = (selectedSpaceIds: string[]) =>
@@ -88,6 +90,7 @@ export const ShareToSpaceForm = (props: Props) => {
onChange={(selection) => setSelectedSpaceIds(selection)}
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
+ prohibitedSpaces={prohibitedSpaces}
/>
>
);
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 beb6e94e5dced..6d37b745fcde7 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
@@ -434,6 +434,25 @@
}
}
+{
+ "type": "doc",
+ "value": {
+ "id": "sharedtype:space_1_only_matching_origin",
+ "index": ".kibana",
+ "source": {
+ "originId": "space_1_only",
+ "sharedtype": {
+ "title": "This object only exists to test the second assertion for spacesWithMatchingOrigins in get_shareable_references"
+ },
+ "type": "sharedtype",
+ "namespaces": ["other_space"],
+ "references": [],
+ "updated_at": "2017-09-21T18:59:16.270Z"
+ },
+ "type": "doc"
+ }
+}
+
{
"type": "doc",
"value": {
@@ -454,6 +473,25 @@
}
}
+{
+ "type": "doc",
+ "value": {
+ "id": "sharedtype:space_2_only_matching_origin",
+ "index": ".kibana",
+ "source": {
+ "originId": "space_2_only",
+ "sharedtype": {
+ "title": "This object only exists to test the third assertion for spacesWithMatchingOrigins in get_shareable_references"
+ },
+ "type": "sharedtype",
+ "namespaces": ["*"],
+ "references": [],
+ "updated_at": "2017-09-21T18:59:16.270Z"
+ },
+ "type": "doc"
+ }
+}
+
{
"type": "doc",
"value": {
diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts
index d6c429b441341..a1c73125ede28 100644
--- a/x-pack/test/spaces_api_integration/common/suites/delete.ts
+++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts
@@ -101,17 +101,17 @@ export function deleteTestSuiteFactory(es: Client, esArchiver: any, supertest: S
expect(buckets).to.eql(expectedBuckets);
- // There were 22 multi-namespace objects.
+ // There were 24 multi-namespace objects.
// Since Space 2 was deleted, any multi-namespace objects that existed in that space
// are updated to remove it, and of those, any that don't exist in any space are deleted.
const multiNamespaceResponse = await es.search>({
index: '.kibana',
- size: 20,
+ size: 100,
body: { query: { terms: { type: ['sharedtype'] } } },
});
const docs = multiNamespaceResponse.hits.hits;
- // Just 17 results, since spaces_2_only, conflict_1a_space_2, conflict_1b_space_2, conflict_1c_space_2, and conflict_2_space_2 got deleted.
- expect(docs).length(17);
+ // Just 19 results, since spaces_2_only, conflict_1a_space_2, conflict_1b_space_2, conflict_1c_space_2, and conflict_2_space_2 got deleted.
+ expect(docs).length(19);
docs.forEach((doc) => () => {
const containsSpace2 = doc?._source?.namespaces.includes('space_2');
expect(containsSpace2).to.eql(false);
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
index 0030932f3f36a..fb6c22a761f1e 100644
--- 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
@@ -51,6 +51,7 @@ export const EXPECTED_RESULTS: Record
{
...TEST_CASE_OBJECTS.SHAREABLE_TYPE,
spaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID],
+ spacesWithMatchingOrigins: [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
},
{
@@ -64,6 +65,7 @@ export const EXPECTED_RESULTS: Record
type: 'sharedtype',
id: CASES.DEFAULT_ONLY.id,
spaces: [DEFAULT_SPACE_ID],
+ spacesWithMatchingOrigins: [DEFAULT_SPACE_ID], // The first test assertion for spacesWithMatchingOrigins is an object that doesn't have any matching origins in other spaces
inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }],
},
{
@@ -84,6 +86,7 @@ export const EXPECTED_RESULTS: Record
type: 'sharedtype',
id: CASES.ALL_SPACES.id,
spaces: ['*'],
+ spacesWithMatchingOrigins: ['*'],
inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }],
},
],
@@ -91,6 +94,7 @@ export const EXPECTED_RESULTS: Record
{
...TEST_CASE_OBJECTS.SHAREABLE_TYPE,
spaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID],
+ spacesWithMatchingOrigins: [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
},
{
@@ -111,8 +115,9 @@ export const EXPECTED_RESULTS: Record
type: 'sharedtype',
id: CASES.SPACE_1_ONLY.id,
spaces: [SPACE_1_ID],
- inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }],
spacesWithMatchingAliases: [DEFAULT_SPACE_ID, SPACE_2_ID], // aliases with a matching targetType and sourceId exist in two other spaces
+ spacesWithMatchingOrigins: ['other_space', SPACE_1_ID], // The second test assertion for spacesWithMatchingOrigins is an object that has a matching origin in one other space
+ inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }],
},
{
type: 'sharedtype',
@@ -125,6 +130,7 @@ export const EXPECTED_RESULTS: Record
type: 'sharedtype',
id: CASES.ALL_SPACES.id,
spaces: ['*'],
+ spacesWithMatchingOrigins: ['*'],
inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }],
},
],
@@ -132,6 +138,7 @@ export const EXPECTED_RESULTS: Record
{
...TEST_CASE_OBJECTS.SHAREABLE_TYPE,
spaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID],
+ spacesWithMatchingOrigins: [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
},
{
@@ -159,12 +166,14 @@ export const EXPECTED_RESULTS: Record
type: 'sharedtype',
id: CASES.SPACE_2_ONLY.id,
spaces: [SPACE_2_ID],
+ spacesWithMatchingOrigins: ['*'], // The third test assertion for spacesWithMatchingOrigins is an object that has a matching origin in all spaces (this takes precedence, causing SPACE_2_ID to be omitted)
inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }],
},
{
type: 'sharedtype',
id: CASES.ALL_SPACES.id,
spaces: ['*'],
+ spacesWithMatchingOrigins: ['*'],
inboundReferences: [{ ...TEST_CASE_OBJECTS.SHAREABLE_TYPE, name: 'refname' }],
},
],
@@ -177,7 +186,7 @@ const getTestTitle = ({ objects }: GetShareableReferencesTestCase) => {
};
const getRedactedSpaces = (authorizedSpace: string | undefined, spaces: string[]) => {
if (!authorizedSpace) {
- return spaces; // if authorizedSpace is undefined, we should not redact any spaces
+ return spaces.sort(); // 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
@@ -200,17 +209,23 @@ export function getShareableReferencesTestSuiteFactory(esArchiver: any, supertes
const apiResponse = response.body as SavedObjectsCollectMultiNamespaceReferencesResponse;
expect(apiResponse.objects).to.have.length(expectedResults.length);
expectedResults.forEach((expectedResult, i) => {
- const { spaces, spacesWithMatchingAliases } = expectedResult;
+ const { spaces, spacesWithMatchingAliases, spacesWithMatchingOrigins } = expectedResult;
const expectedSpaces = getRedactedSpaces(authorizedSpace, spaces);
const expectedSpacesWithMatchingAliases =
spacesWithMatchingAliases &&
getRedactedSpaces(authorizedSpace, spacesWithMatchingAliases);
+ const expectedSpacesWithMatchingOrigins =
+ spacesWithMatchingOrigins &&
+ getRedactedSpaces(authorizedSpace, spacesWithMatchingOrigins);
const expected = {
...expectedResult,
spaces: expectedSpaces,
...(expectedSpacesWithMatchingAliases && {
spacesWithMatchingAliases: expectedSpacesWithMatchingAliases,
}),
+ ...(expectedSpacesWithMatchingOrigins && {
+ spacesWithMatchingOrigins: expectedSpacesWithMatchingOrigins,
+ }),
};
expect(apiResponse.objects[i]).to.eql(expected);
});