mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
(cherry picked from commit cde42887d3
)
Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com>
This commit is contained in:
parent
64352ddfc7
commit
4f82d39913
25 changed files with 659 additions and 54 deletions
|
@ -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 | <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 |
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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)
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
originId?: string;
|
||||
```
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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)
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
spacesWithMatchingOrigins?: string[];
|
||||
```
|
|
@ -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 | <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 |
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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)
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
originId?: string;
|
||||
```
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[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)
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
spacesWithMatchingOrigins?: string[];
|
||||
```
|
|
@ -1050,8 +1050,10 @@ export interface SavedObjectReferenceWithContext {
|
|||
name: string;
|
||||
}>;
|
||||
isMissing?: boolean;
|
||||
originId?: string;
|
||||
spaces: string[];
|
||||
spacesWithMatchingAliases?: string[];
|
||||
spacesWithMatchingOrigins?: string[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -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']
|
||||
>;
|
||||
|
|
|
@ -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!'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -2001,8 +2001,10 @@ export interface SavedObjectReferenceWithContext {
|
|||
name: string;
|
||||
}>;
|
||||
isMissing?: boolean;
|
||||
originId?: string;
|
||||
spaces: string[];
|
||||
spacesWithMatchingAliases?: string[];
|
||||
spacesWithMatchingOrigins?: string[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue