Change updateObjectsSpaces API to prevent multiple objects w/ same origin (#128269) (#130620)

(cherry picked from commit cde42887d3)

Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2022-04-19 17:17:43 -05:00 committed by GitHub
parent 64352ddfc7
commit 4f82d39913
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 659 additions and 54 deletions

View file

@ -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&lt;{ type: string; id: string; name: string; }&gt; | 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 | <i>(Optional)</i> Whether or not this object or reference is missing |
| [originId?](./kibana-plugin-core-public.savedobjectreferencewithcontext.originid.md) | string | <i>(Optional)</i> 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\[\] | <i>(Optional)</i> The space(s) that legacy URL aliases matching this type/id exist in |
| [spacesWithMatchingOrigins?](./kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingorigins.md) | string\[\] | <i>(Optional)</i> 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 |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) &gt; [originId](./kibana-plugin-core-public.savedobjectreferencewithcontext.originid.md)
## SavedObjectReferenceWithContext.originId property
The origin ID of the referenced object (if it has one)
<b>Signature:</b>
```typescript
originId?: string;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) &gt; [spacesWithMatchingOrigins](./kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingorigins.md)
## SavedObjectReferenceWithContext.spacesWithMatchingOrigins property
The space(s) that objects matching this origin exist in (including this one)
<b>Signature:</b>
```typescript
spacesWithMatchingOrigins?: string[];
```

View file

@ -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&lt;{ type: string; id: string; name: string; }&gt; | 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 | <i>(Optional)</i> Whether or not this object or reference is missing |
| [originId?](./kibana-plugin-core-server.savedobjectreferencewithcontext.originid.md) | string | <i>(Optional)</i> 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\[\] | <i>(Optional)</i> The space(s) that legacy URL aliases matching this type/id exist in |
| [spacesWithMatchingOrigins?](./kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingorigins.md) | string\[\] | <i>(Optional)</i> 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 |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) &gt; [originId](./kibana-plugin-core-server.savedobjectreferencewithcontext.originid.md)
## SavedObjectReferenceWithContext.originId property
The origin ID of the referenced object (if it has one)
<b>Signature:</b>
```typescript
originId?: string;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) &gt; [spacesWithMatchingOrigins](./kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingorigins.md)
## SavedObjectReferenceWithContext.spacesWithMatchingOrigins property
The space(s) that objects matching this origin exist in (including this one)
<b>Signature:</b>
```typescript
spacesWithMatchingOrigins?: string[];
```

View file

@ -1050,8 +1050,10 @@ export interface SavedObjectReferenceWithContext {
name: string;
}>;
isMissing?: boolean;
originId?: string;
spaces: string[];
spacesWithMatchingAliases?: string[];
spacesWithMatchingOrigins?: string[];
type: string;
}

View file

@ -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']
>;

View file

@ -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!'
);
});
});
});

View file

@ -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 {

View file

@ -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<ISavedObjectsRepository>;
let pointInTimeFinder: DeeplyMockedKeys<PointInTimeFinder>;
let createPointInTimeFinder: jest.MockedFunction<CreatePointInTimeFinderFn>;
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);
});
});

View file

@ -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<string, Set<string>>();
}
const uniqueObjectTypes = objects.reduce((acc, { type }) => acc.add(type), new Set<string>());
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<string, Set<string>>();
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<string>();
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<unknown[]>((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);
}

View file

@ -2001,8 +2001,10 @@ export interface SavedObjectReferenceWithContext {
name: string;
}>;
isMissing?: boolean;
originId?: string;
spaces: string[];
spacesWithMatchingAliases?: string[];
spacesWithMatchingOrigins?: string[];
type: string;
}

View file

@ -33,7 +33,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',

View file

@ -33,10 +33,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',

View file

@ -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)

View file

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

View file

@ -259,7 +259,7 @@ export const CopyToSpaceFlyoutInternal = (props: CopyToSpaceFlyoutProps) => {
<h2>
<FormattedMessage
id="xpack.spaces.management.copyToSpaceFlyoutHeader"
defaultMessage="Copy to space"
defaultMessage="Copy to spaces"
/>
</h2>
</EuiTitle>

View file

@ -43,6 +43,7 @@ interface Props {
onChange: (selectedSpaceIds: string[]) => void;
enableCreateNewSpaceLink: boolean;
enableSpaceAgnosticBehavior: boolean;
prohibitedSpaces: Set<string>;
}
type SpaceOption = EuiSelectableOption & { ['data-space-id']: string };
@ -73,6 +74,18 @@ const APPEND_CANNOT_DESELECT = (
type="iInCircle"
/>
);
const APPEND_PROHIBITED = (
<EuiIconTip
title={i18n.translate('xpack.spaces.shareToSpace.prohibitedSpaceTooltipTitle', {
defaultMessage: 'Cannot share to this space',
})}
content={i18n.translate('xpack.spaces.shareToSpace.prohibitedSpaceTooltip', {
defaultMessage: 'A copy of this saved object exists in this space.',
})}
position="left"
type="iInCircle"
/>
);
const APPEND_FEATURE_IS_DISABLED = (
<EuiIconTip
content={i18n.translate('xpack.spaces.shareToSpace.featureIsDisabledTooltip', {
@ -85,8 +98,14 @@ const APPEND_FEATURE_IS_DISABLED = (
);
export const SelectableSpacesControl = (props: Props) => {
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<string>
) {
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,

View file

@ -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<string>;
}
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 = (
<EuiIconTip
content={i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotChangeTooltip',
{ defaultMessage: 'You need additional privileges to change this option.' }
)}
position="left"
type="iInCircle"
/>
);
const ALL_SPACES_PROHIBITED_TOOLTIP = (
<EuiIconTip
title={i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.allSpacesProhibitedTooltipTitle',
{ defaultMessage: 'Cannot share to all spaces' }
)}
content={i18n.translate(
'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.allSpacesProhibitedTooltipContent',
{ defaultMessage: 'A copy of this saved object exists in at least one other space.' }
)}
position="left"
type="iInCircle"
/>
);
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}
/>
<EuiSpacer size="s" />
@ -173,11 +205,7 @@ export const ShareModeControl = (props: Props) => {
)}
</EuiText>
</EuiFlexItem>
{!canShareToAllSpaces && (
<EuiFlexItem grow={false}>
<EuiIconTip content={cannotChangeTooltip} position="left" type="iInCircle" />
</EuiFlexItem>
)}
{globalControlTooltip && <EuiFlexItem grow={false}>{globalControlTooltip}</EuiFlexItem>}
</EuiFlexGroup>
</EuiFlexItem>
@ -190,6 +218,7 @@ export const ShareModeControl = (props: Props) => {
onChange={onChange}
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
prohibitedSpaces={prohibitedSpaces}
/>
</EuiFlexGroup>
</>

View file

@ -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<string>; // 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<boolean>(false);
const [showMakeCopy, setShowMakeCopy] = useState<boolean>(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<SpacesState>({
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<InternalLegacyUrlAliasTarget[]>(
(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<string>()),
});
})
.catch((e) => {
@ -329,6 +353,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
makeCopy={() => setShowMakeCopy(true)}
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
prohibitedSpaces={prohibitedSpaces}
/>
);
}

View file

@ -26,6 +26,7 @@ interface Props {
makeCopy: () => void;
enableCreateNewSpaceLink: boolean;
enableSpaceAgnosticBehavior: boolean;
prohibitedSpaces: Set<string>;
}
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}
/>
</>
);

View file

@ -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": {

View file

@ -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<Record<string, any>>({
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);

View file

@ -51,6 +51,7 @@ export const EXPECTED_RESULTS: Record<string, SavedObjectReferenceWithContext[]>
{
...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<string, SavedObjectReferenceWithContext[]>
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<string, SavedObjectReferenceWithContext[]>
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<string, SavedObjectReferenceWithContext[]>
{
...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<string, SavedObjectReferenceWithContext[]>
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<string, SavedObjectReferenceWithContext[]>
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<string, SavedObjectReferenceWithContext[]>
{
...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<string, SavedObjectReferenceWithContext[]>
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);
});