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