= ({
>
)}
+ {sharedObjectsCount > 0 && (
+ <>
+
+ }
+ iconType="alert"
+ color="warning"
+ >
+
+
+
+
+
+ >
+ )}
= ({
{ defaultMessage: 'Type' }
),
width: '50px',
- render: (type, object) => (
+ render: (type, { icon }) => (
-
+
),
},
@@ -124,7 +157,7 @@ export const DeleteConfirmModal: FC = ({
),
},
{
- field: 'meta.title',
+ field: 'title',
name: i18n.translate(
'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName',
{ defaultMessage: 'Title' }
@@ -157,7 +190,8 @@ export const DeleteConfirmModal: FC = ({
>
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx
index ad61b0b692ea7..4e4bd51c4bb84 100644
--- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx
+++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx
@@ -48,7 +48,7 @@ export interface TableProps {
filterOptions: any[];
capabilities: ApplicationStart['capabilities'];
onDelete: () => void;
- onActionRefresh: (object: SavedObjectWithMetadata) => void;
+ onActionRefresh: (objects: Array<{ type: string; id: string }>) => void;
onExport: (includeReferencesDeep: boolean) => void;
goInspectObject: (obj: SavedObjectWithMetadata) => void;
pageIndex: number;
@@ -277,10 +277,9 @@ export class Table extends PureComponent {
this.setState({
activeAction: undefined,
});
- const { refreshOnFinish = () => false } = action;
- if (refreshOnFinish()) {
- onActionRefresh(object);
- }
+ const { refreshOnFinish = () => [] } = action;
+ const objectsToRefresh = refreshOnFinish();
+ onActionRefresh(objectsToRefresh);
});
if (action.euiAction.onClick) {
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx
index 42c1220ef5540..5ea433f91d1a6 100644
--- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx
+++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx
@@ -30,7 +30,7 @@ import {
fetchExportObjects,
fetchExportByTypeAndSearch,
findObjects,
- findObject,
+ bulkGetObjects,
extractExportDetails,
SavedObjectsExportResultDetails,
getTagFindReferences,
@@ -96,6 +96,14 @@ export interface SavedObjectsTableState {
isIncludeReferencesDeepChecked: boolean;
}
+const unableFindSavedObjectsNotificationMessage = i18n.translate(
+ 'savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage',
+ { defaultMessage: 'Unable find saved objects' }
+);
+const unableFindSavedObjectNotificationMessage = i18n.translate(
+ 'savedObjectsManagement.objectsTable.unableFindSavedObjectNotificationMessage',
+ { defaultMessage: 'Unable to find saved object' }
+);
export class SavedObjectsTable extends Component {
private _isMounted = false;
@@ -129,13 +137,14 @@ export class SavedObjectsTable extends Component {
@@ -188,15 +197,15 @@ export class SavedObjectsTable extends Component {
- this.setState({ isSearching: true }, this.debouncedFetchObjects);
+ fetchAllSavedObjects = () => {
+ this.setState({ isSearching: true }, this.debouncedFindObjects);
};
- fetchSavedObject = (type: string, id: string) => {
- this.setState({ isSearching: true }, () => this.debouncedFetchObject(type, id));
+ fetchSavedObjects = (objects: Array<{ type: string; id: string }>) => {
+ this.setState({ isSearching: true }, () => this.debouncedBulkGetObjects(objects));
};
- debouncedFetchObjects = debounce(async () => {
+ debouncedFindObjects = debounce(async () => {
const { activeQuery: query, page, perPage } = this.state;
const { notifications, http, allowedTypes, taggingApi } = this.props;
const { queryText, visibleTypes, selectedTags } = parseQuery(query);
@@ -240,27 +249,45 @@ export class SavedObjectsTable extends Component {
+ debouncedBulkGetObjects = debounce(async (objects: Array<{ type: string; id: string }>) => {
const { notifications, http } = this.props;
try {
- const resp = await findObject(http, type, id);
+ const resp = await bulkGetObjects(http, objects);
if (!this._isMounted) {
return;
}
+ const { map: fetchedObjectsMap, errors: objectErrors } = resp.reduce(
+ ({ map, errors }, obj) => {
+ if (obj.error) {
+ errors.push(obj.error.message);
+ } else {
+ map.set(getObjectKey(obj), obj);
+ }
+ return { map, errors };
+ },
+ { map: new Map(), errors: [] as string[] }
+ );
+
+ if (objectErrors.length) {
+ notifications.toasts.addDanger({
+ title: unableFindSavedObjectNotificationMessage,
+ text: objectErrors.join(', '),
+ });
+ }
+
this.setState(({ savedObjects, filteredItemCount }) => {
- const refreshedSavedObjects = savedObjects.map((object) =>
- object.type === type && object.id === id ? resp : object
- );
+ // modify the existing objects array, replacing any existing objects with the newly fetched ones
+ const refreshedSavedObjects = savedObjects.map((obj) => {
+ const fetchedObject = fetchedObjectsMap.get(getObjectKey(obj));
+ return fetchedObject ?? obj;
+ });
return {
savedObjects: refreshedSavedObjects,
filteredItemCount,
@@ -274,21 +301,25 @@ export class SavedObjectsTable extends Component {
- await Promise.all([this.fetchSavedObjects(), this.fetchCounts()]);
+ refreshAllObjects = async () => {
+ await Promise.all([this.fetchAllSavedObjects(), this.fetchCounts()]);
};
- refreshObject = async ({ type, id }: SavedObjectWithMetadata) => {
- await this.fetchSavedObject(type, id);
+ refreshObjects = async (objects: Array<{ type: string; id: string }>) => {
+ const currentObjectsSet = this.state.savedObjects.reduce(
+ (acc, obj) => acc.add(getObjectKey(obj)),
+ new Set()
+ );
+ const objectsToFetch = objects.filter((obj) => currentObjectsSet.has(getObjectKey(obj)));
+ if (objectsToFetch.length) {
+ this.fetchSavedObjects(objectsToFetch);
+ }
};
onSelectionChanged = (selection: SavedObjectWithMetadata[]) => {
@@ -305,7 +336,7 @@ export class SavedObjectsTable extends Component {
- this.fetchSavedObjects();
+ this.fetchAllSavedObjects();
this.fetchCounts();
}
);
@@ -320,7 +351,7 @@ export class SavedObjectsTable extends Component {
this.hideImportFlyout();
- this.fetchSavedObjects();
+ this.fetchAllSavedObjects();
this.fetchCounts();
};
@@ -480,7 +511,7 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })}
onImport={this.showImportFlyout}
- onRefresh={this.refreshObjects}
+ onRefresh={this.refreshAllObjects}
filteredCount={filteredItemCount}
/>
@@ -645,7 +676,7 @@ export class SavedObjectsTable extends Component void;
render?: (item: SavedObjectsManagementRecord) => any;
};
- public refreshOnFinish?: () => boolean;
+ public refreshOnFinish?: () => Array<{ type: string; id: string }>;
private callbacks: Function[] = [];
diff --git a/src/plugins/saved_objects_management/server/routes/get.ts b/src/plugins/saved_objects_management/server/routes/bulk_get.ts
similarity index 52%
rename from src/plugins/saved_objects_management/server/routes/get.ts
rename to src/plugins/saved_objects_management/server/routes/bulk_get.ts
index 5a48f2f2affa7..c277653b6f350 100644
--- a/src/plugins/saved_objects_management/server/routes/get.ts
+++ b/src/plugins/saved_objects_management/server/routes/bulk_get.ts
@@ -11,34 +11,42 @@ import { IRouter } from 'src/core/server';
import { injectMetaAttributes } from '../lib';
import { ISavedObjectsManagement } from '../services';
-export const registerGetRoute = (
+export const registerBulkGetRoute = (
router: IRouter,
managementServicePromise: Promise
) => {
- router.get(
+ router.post(
{
- path: '/api/kibana/management/saved_objects/{type}/{id}',
+ path: '/api/kibana/management/saved_objects/_bulk_get',
validate: {
- params: schema.object({
- type: schema.string(),
- id: schema.string(),
- }),
+ body: schema.arrayOf(
+ schema.object({
+ type: schema.string(),
+ id: schema.string(),
+ })
+ ),
},
},
router.handleLegacyErrors(async (context, req, res) => {
- const { type, id } = req.params;
const managementService = await managementServicePromise;
const { getClient, typeRegistry } = context.core.savedObjects;
- const includedHiddenTypes = [type].filter(
- (entry) => typeRegistry.isHidden(entry) && typeRegistry.isImportableAndExportable(entry)
+
+ const objects = req.body;
+ const uniqueTypes = objects.reduce((acc, { type }) => acc.add(type), new Set());
+ const includedHiddenTypes = Array.from(uniqueTypes).filter(
+ (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type)
);
const client = getClient({ includedHiddenTypes });
- const findResponse = await client.get(type, id);
-
- const enhancedSavedObject = injectMetaAttributes(findResponse, managementService);
+ const response = await client.bulkGet(objects);
+ const enhancedObjects = response.saved_objects.map((obj) => {
+ if (!obj.error) {
+ return injectMetaAttributes(obj, managementService);
+ }
+ return obj;
+ });
- return res.ok({ body: enhancedSavedObject });
+ return res.ok({ body: enhancedObjects });
})
);
};
diff --git a/src/plugins/saved_objects_management/server/routes/index.test.ts b/src/plugins/saved_objects_management/server/routes/index.test.ts
index 7fbc54cbaf556..3ec6afe1c0bbc 100644
--- a/src/plugins/saved_objects_management/server/routes/index.test.ts
+++ b/src/plugins/saved_objects_management/server/routes/index.test.ts
@@ -23,8 +23,8 @@ describe('registerRoutes', () => {
});
expect(httpSetup.createRouter).toHaveBeenCalledTimes(1);
- expect(router.get).toHaveBeenCalledTimes(4);
- expect(router.post).toHaveBeenCalledTimes(2);
+ expect(router.get).toHaveBeenCalledTimes(3);
+ expect(router.post).toHaveBeenCalledTimes(3);
expect(router.get).toHaveBeenCalledWith(
expect.objectContaining({
@@ -32,9 +32,9 @@ describe('registerRoutes', () => {
}),
expect.any(Function)
);
- expect(router.get).toHaveBeenCalledWith(
+ expect(router.post).toHaveBeenCalledWith(
expect.objectContaining({
- path: '/api/kibana/management/saved_objects/{type}/{id}',
+ path: '/api/kibana/management/saved_objects/_bulk_get',
}),
expect.any(Function)
);
diff --git a/src/plugins/saved_objects_management/server/routes/index.ts b/src/plugins/saved_objects_management/server/routes/index.ts
index 44453fccf88ed..b5b461575604d 100644
--- a/src/plugins/saved_objects_management/server/routes/index.ts
+++ b/src/plugins/saved_objects_management/server/routes/index.ts
@@ -9,7 +9,7 @@
import { HttpServiceSetup } from 'src/core/server';
import { ISavedObjectsManagement } from '../services';
import { registerFindRoute } from './find';
-import { registerGetRoute } from './get';
+import { registerBulkGetRoute } from './bulk_get';
import { registerScrollForCountRoute } from './scroll_count';
import { registerScrollForExportRoute } from './scroll_export';
import { registerRelationshipsRoute } from './relationships';
@@ -23,7 +23,7 @@ interface RegisterRouteOptions {
export function registerRoutes({ http, managementServicePromise }: RegisterRouteOptions) {
const router = http.createRouter();
registerFindRoute(router, managementServicePromise);
- registerGetRoute(router, managementServicePromise);
+ registerBulkGetRoute(router, managementServicePromise);
registerScrollForCountRoute(router);
registerScrollForExportRoute(router);
registerRelationshipsRoute(router, managementServicePromise);
diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts
index ddee9c0528ba1..b1b6a16958dbd 100644
--- a/src/plugins/spaces_oss/public/api.ts
+++ b/src/plugins/spaces_oss/public/api.ts
@@ -168,15 +168,19 @@ export interface ShareToSpaceFlyoutProps {
*/
behaviorContext?: 'within-space' | 'outside-space';
/**
- * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object. If
- * this is not defined, a default handler will be used that calls `/api/spaces/_update_objects_spaces` and displays a toast indicating
- * what occurred.
+ * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object and
+ * its relatives. If this is not defined, a default handler will be used that calls `/api/spaces/_update_objects_spaces` and displays a
+ * toast indicating what occurred.
*/
- changeSpacesHandler?: (spacesToAdd: string[], spacesToRemove: string[]) => Promise;
+ changeSpacesHandler?: (
+ objects: Array<{ type: string; id: string }>,
+ spacesToAdd: string[],
+ spacesToRemove: string[]
+ ) => Promise;
/**
- * Optional callback when the target object is updated.
+ * Optional callback when the target object and its relatives are updated.
*/
- onUpdate?: () => void;
+ onUpdate?: (updatedObjects: Array<{ type: string; id: string }>) => void;
/**
* Optional callback when the flyout is closed.
*/
@@ -288,4 +292,11 @@ export interface SpaceAvatarProps {
* Default value is true.
*/
announceSpaceName?: boolean;
+
+ /**
+ * Whether or not to render the avatar in a disabled state.
+ *
+ * Default value is false.
+ */
+ isDisabled?: boolean;
}
diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json
index f8edfdcc5c364..56fc7697a4e07 100644
--- a/src/plugins/telemetry/schema/oss_plugins.json
+++ b/src/plugins/telemetry/schema/oss_plugins.json
@@ -7188,6 +7188,34 @@
}
}
}
+ },
+ "legacyUrlAliases": {
+ "properties": {
+ "inactiveCount": {
+ "type": "long",
+ "_meta": {
+ "description": "Count of legacy URL aliases that are inactive; they are not disabled, but they have not been resolved."
+ }
+ },
+ "activeCount": {
+ "type": "long",
+ "_meta": {
+ "description": "Count of legacy URL aliases that are active; they are not disabled, and they have been resolved at least once."
+ }
+ },
+ "disabledCount": {
+ "type": "long",
+ "_meta": {
+ "description": "Count of legacy URL aliases that are disabled."
+ }
+ },
+ "totalCount": {
+ "type": "long",
+ "_meta": {
+ "description": "Total count of legacy URL aliases."
+ }
+ }
+ }
}
}
}
@@ -7744,6 +7772,36 @@
"_meta": {
"description": "How many times this API has been called without all types selected."
}
+ },
+ "savedObjectsRepository.resolvedOutcome.exactMatch": {
+ "type": "long",
+ "_meta": {
+ "description": "How many times a saved object has resolved with an exact match outcome."
+ }
+ },
+ "savedObjectsRepository.resolvedOutcome.aliasMatch": {
+ "type": "long",
+ "_meta": {
+ "description": "How many times a saved object has resolved with an alias match outcome."
+ }
+ },
+ "savedObjectsRepository.resolvedOutcome.conflict": {
+ "type": "long",
+ "_meta": {
+ "description": "How many times a saved object has resolved with a conflict outcome."
+ }
+ },
+ "savedObjectsRepository.resolvedOutcome.notFound": {
+ "type": "long",
+ "_meta": {
+ "description": "How many times a saved object has resolved with a not found outcome."
+ }
+ },
+ "savedObjectsRepository.resolvedOutcome.total": {
+ "type": "long",
+ "_meta": {
+ "description": "How many times a saved object has resolved with any of the four possible outcomes."
+ }
}
}
},
diff --git a/test/api_integration/apis/saved_objects_management/bulk_get.ts b/test/api_integration/apis/saved_objects_management/bulk_get.ts
new file mode 100644
index 0000000000000..35fa03a41770c
--- /dev/null
+++ b/test/api_integration/apis/saved_objects_management/bulk_get.ts
@@ -0,0 +1,83 @@
+/*
+ * 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 expect from '@kbn/expect';
+import type { Response } from 'supertest';
+import type { FtrProviderContext } from '../../ftr_provider_context';
+
+export default function ({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+ const kibanaServer = getService('kibanaServer');
+
+ describe('_bulk_get', () => {
+ const URL = '/api/kibana/management/saved_objects/_bulk_get';
+ const validObject = { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' };
+ const invalidObject = { type: 'wigwags', id: 'foo' };
+
+ before(() =>
+ kibanaServer.importExport.load(
+ 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
+ )
+ );
+ after(() =>
+ kibanaServer.importExport.unload(
+ 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
+ )
+ );
+
+ function expectSuccess(index: number, { body }: Response) {
+ const { type, id, meta, error } = body[index];
+ expect(type).to.eql(validObject.type);
+ expect(id).to.eql(validObject.id);
+ expect(meta).to.not.equal(undefined);
+ expect(error).to.equal(undefined);
+ }
+
+ function expectBadRequest(index: number, { body }: Response) {
+ const { type, id, error } = body[index];
+ expect(type).to.eql(invalidObject.type);
+ expect(id).to.eql(invalidObject.id);
+ expect(error).to.eql({
+ message: `Unsupported saved object type: '${invalidObject.type}': Bad Request`,
+ statusCode: 400,
+ error: 'Bad Request',
+ });
+ }
+
+ it('should return 200 for object that exists and inject metadata', async () =>
+ await supertest
+ .post(URL)
+ .send([validObject])
+ .expect(200)
+ .then((response: Response) => {
+ expect(response.body).to.have.length(1);
+ expectSuccess(0, response);
+ }));
+
+ it('should return error for invalid object type', async () =>
+ await supertest
+ .post(URL)
+ .send([invalidObject])
+ .expect(200)
+ .then((response: Response) => {
+ expect(response.body).to.have.length(1);
+ expectBadRequest(0, response);
+ }));
+
+ it('should return mix of successes and errors', async () =>
+ await supertest
+ .post(URL)
+ .send([validObject, invalidObject])
+ .expect(200)
+ .then((response: Response) => {
+ expect(response.body).to.have.length(2);
+ expectSuccess(0, response);
+ expectBadRequest(1, response);
+ }));
+ });
+}
diff --git a/test/api_integration/apis/saved_objects_management/get.ts b/test/api_integration/apis/saved_objects_management/get.ts
deleted file mode 100644
index 3b49a28ca4022..0000000000000
--- a/test/api_integration/apis/saved_objects_management/get.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0 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 expect from '@kbn/expect';
-import { Response } from 'supertest';
-import { FtrProviderContext } from '../../ftr_provider_context';
-
-export default function ({ getService }: FtrProviderContext) {
- const supertest = getService('supertest');
- const kibanaServer = getService('kibanaServer');
-
- describe('get', () => {
- const existingObject = 'visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab';
- const nonexistentObject = 'wigwags/foo';
-
- before(async () => {
- await kibanaServer.importExport.load(
- 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
- );
- });
- after(async () => {
- await kibanaServer.importExport.unload(
- 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
- );
- });
-
- it('should return 200 for object that exists and inject metadata', async () =>
- await supertest
- .get(`/api/kibana/management/saved_objects/${existingObject}`)
- .expect(200)
- .then((resp: Response) => {
- const { body } = resp;
- const { type, id, meta } = body;
- expect(type).to.eql('visualization');
- expect(id).to.eql('dd7caf20-9efd-11e7-acb3-3dab96693fab');
- expect(meta).to.not.equal(undefined);
- }));
-
- it('should return 404 for object that does not exist', async () =>
- await supertest.get(`/api/kibana/management/saved_objects/${nonexistentObject}`).expect(404));
- });
-}
diff --git a/test/api_integration/apis/saved_objects_management/index.ts b/test/api_integration/apis/saved_objects_management/index.ts
index 3af5699ca0458..208ded1d50706 100644
--- a/test/api_integration/apis/saved_objects_management/index.ts
+++ b/test/api_integration/apis/saved_objects_management/index.ts
@@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('saved objects management apis', () => {
loadTestFile(require.resolve('./find'));
- loadTestFile(require.resolve('./get'));
+ loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./relationships'));
loadTestFile(require.resolve('./scroll_count'));
});
diff --git a/test/plugin_functional/test_suites/saved_objects_management/bulk_get.ts b/test/plugin_functional/test_suites/saved_objects_management/bulk_get.ts
new file mode 100644
index 0000000000000..b792df4244e60
--- /dev/null
+++ b/test/plugin_functional/test_suites/saved_objects_management/bulk_get.ts
@@ -0,0 +1,93 @@
+/*
+ * 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 expect from '@kbn/expect';
+import type { Response } from 'supertest';
+import type { PluginFunctionalProviderContext } from '../../services';
+
+export default function ({ getService }: PluginFunctionalProviderContext) {
+ const supertest = getService('supertest');
+ const esArchiver = getService('esArchiver');
+
+ describe('_bulk_get', () => {
+ describe('saved objects with hidden type', () => {
+ before(() =>
+ esArchiver.load(
+ 'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects'
+ )
+ );
+ after(() =>
+ esArchiver.unload(
+ 'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects'
+ )
+ );
+ const URL = '/api/kibana/management/saved_objects/_bulk_get';
+ const hiddenTypeExportableImportable = {
+ type: 'test-hidden-importable-exportable',
+ id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab',
+ };
+ const hiddenTypeNonExportableImportable = {
+ type: 'test-hidden-non-importable-exportable',
+ id: 'op3767a1-9rcg-53u7-jkb3-3dnb74193awc',
+ };
+
+ function expectSuccess(index: number, { body }: Response) {
+ const { type, id, meta, error } = body[index];
+ expect(type).to.eql(hiddenTypeExportableImportable.type);
+ expect(id).to.eql(hiddenTypeExportableImportable.id);
+ expect(meta).to.not.equal(undefined);
+ expect(error).to.equal(undefined);
+ }
+
+ function expectBadRequest(index: number, { body }: Response) {
+ const { type, id, error } = body[index];
+ expect(type).to.eql(hiddenTypeNonExportableImportable.type);
+ expect(id).to.eql(hiddenTypeNonExportableImportable.id);
+ expect(error).to.eql({
+ message: `Unsupported saved object type: '${hiddenTypeNonExportableImportable.type}': Bad Request`,
+ statusCode: 400,
+ error: 'Bad Request',
+ });
+ }
+
+ it('should return 200 for hidden types that are importableAndExportable', async () =>
+ await supertest
+ .post(URL)
+ .send([hiddenTypeExportableImportable])
+ .set('kbn-xsrf', 'true')
+ .expect(200)
+ .then((response: Response) => {
+ expect(response.body).to.have.length(1);
+ expectSuccess(0, response);
+ }));
+
+ it('should return error for hidden types that are not importableAndExportable', async () =>
+ await supertest
+ .post(URL)
+ .send([hiddenTypeNonExportableImportable])
+ .set('kbn-xsrf', 'true')
+ .expect(200)
+ .then((response: Response) => {
+ expect(response.body).to.have.length(1);
+ expectBadRequest(0, response);
+ }));
+
+ it('should return mix of successes and errors', async () =>
+ await supertest
+ .post(URL)
+ .send([hiddenTypeExportableImportable, hiddenTypeNonExportableImportable])
+ .set('kbn-xsrf', 'true')
+ .expect(200)
+ .then((response: Response) => {
+ expect(response.body).to.have.length(2);
+ expectSuccess(0, response);
+ expectBadRequest(1, response);
+ }));
+ });
+ });
+}
diff --git a/test/plugin_functional/test_suites/saved_objects_management/get.ts b/test/plugin_functional/test_suites/saved_objects_management/get.ts
deleted file mode 100644
index 6a69aa9146f3e..0000000000000
--- a/test/plugin_functional/test_suites/saved_objects_management/get.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0 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 expect from '@kbn/expect';
-import { PluginFunctionalProviderContext } from '../../services';
-
-export default function ({ getService }: PluginFunctionalProviderContext) {
- const supertest = getService('supertest');
- const esArchiver = getService('esArchiver');
-
- describe('get', () => {
- describe('saved objects with hidden type', () => {
- before(() =>
- esArchiver.load(
- 'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects'
- )
- );
- after(() =>
- esArchiver.unload(
- 'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects'
- )
- );
- const hiddenTypeExportableImportable =
- 'test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab';
- const hiddenTypeNonExportableImportable =
- 'test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc';
-
- it('should return 200 for hidden types that are importableAndExportable', async () =>
- await supertest
- .get(`/api/kibana/management/saved_objects/${hiddenTypeExportableImportable}`)
- .set('kbn-xsrf', 'true')
- .expect(200)
- .then((resp) => {
- const { body } = resp;
- const { type, id, meta } = body;
- expect(type).to.eql('test-hidden-importable-exportable');
- expect(id).to.eql('ff3733a0-9fty-11e7-ahb3-3dcb94193fab');
- expect(meta).to.not.equal(undefined);
- }));
-
- it('should return 404 for hidden types that are not importableAndExportable', async () =>
- await supertest
- .get(`/api/kibana/management/saved_objects/${hiddenTypeNonExportableImportable}`)
- .set('kbn-xsrf', 'true')
- .expect(404));
- });
- });
-}
diff --git a/test/plugin_functional/test_suites/saved_objects_management/index.ts b/test/plugin_functional/test_suites/saved_objects_management/index.ts
index edaa819e5ea58..03ac96b9a11f6 100644
--- a/test/plugin_functional/test_suites/saved_objects_management/index.ts
+++ b/test/plugin_functional/test_suites/saved_objects_management/index.ts
@@ -12,7 +12,7 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
describe('Saved Objects Management', function () {
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./scroll_count'));
- loadTestFile(require.resolve('./get'));
+ loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./export_transform'));
loadTestFile(require.resolve('./import_warnings'));
loadTestFile(require.resolve('./hidden_types'));
diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx
index 85d1301fee957..a405f0486430c 100644
--- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx
+++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx
@@ -37,7 +37,11 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType,
const [showFlyout, setShowFlyout] = useState(false);
- async function changeSpacesHandler(spacesToAdd: string[], spacesToMaybeRemove: string[]) {
+ async function changeSpacesHandler(
+ _objects: Array<{ type: string; id: string }>, // this is ignored because ML jobs do not have references
+ spacesToAdd: string[],
+ spacesToMaybeRemove: string[]
+ ) {
// If the user is adding the job to all current and future spaces, don't remove it from any specified spaces
const spacesToRemove = spacesToAdd.includes(ALL_SPACES_ID) ? [] : spacesToMaybeRemove;
diff --git a/x-pack/plugins/security/server/authorization/authorization_service.tsx b/x-pack/plugins/security/server/authorization/authorization_service.tsx
index 0777c231ecd89..72f2c9843daec 100644
--- a/x-pack/plugins/security/server/authorization/authorization_service.tsx
+++ b/x-pack/plugins/security/server/authorization/authorization_service.tsx
@@ -94,6 +94,7 @@ export interface AuthorizationServiceSetup {
actions: Actions;
checkPrivilegesWithRequest: CheckPrivilegesWithRequest;
checkPrivilegesDynamicallyWithRequest: CheckPrivilegesDynamicallyWithRequest;
+ checkSavedObjectsPrivilegesWithRequest: CheckSavedObjectsPrivilegesWithRequest;
mode: AuthorizationMode;
}
diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts
index c30fcd8b69604..f1f858a40a465 100644
--- a/x-pack/plugins/security/server/mocks.ts
+++ b/x-pack/plugins/security/server/mocks.ts
@@ -23,6 +23,7 @@ function createSetupMock() {
actions: mockAuthz.actions,
checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest,
checkPrivilegesDynamicallyWithRequest: mockAuthz.checkPrivilegesDynamicallyWithRequest,
+ checkSavedObjectsPrivilegesWithRequest: mockAuthz.checkSavedObjectsPrivilegesWithRequest,
mode: mockAuthz.mode,
},
registerSpacesService: jest.fn(),
@@ -42,6 +43,7 @@ function createStartMock() {
actions: mockAuthz.actions,
checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest,
checkPrivilegesDynamicallyWithRequest: mockAuthz.checkPrivilegesDynamicallyWithRequest,
+ checkSavedObjectsPrivilegesWithRequest: mockAuthz.checkSavedObjectsPrivilegesWithRequest,
mode: mockAuthz.mode,
},
};
diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts
index 574e37fdd1841..2d17e75527c6f 100644
--- a/x-pack/plugins/security/server/plugin.test.ts
+++ b/x-pack/plugins/security/server/plugin.test.ts
@@ -101,6 +101,7 @@ describe('Security Plugin', () => {
},
"checkPrivilegesDynamicallyWithRequest": [Function],
"checkPrivilegesWithRequest": [Function],
+ "checkSavedObjectsPrivilegesWithRequest": [Function],
"mode": Object {
"useRbacForRequest": [Function],
},
@@ -171,6 +172,7 @@ describe('Security Plugin', () => {
},
"checkPrivilegesDynamicallyWithRequest": [Function],
"checkPrivilegesWithRequest": [Function],
+ "checkSavedObjectsPrivilegesWithRequest": [Function],
"mode": Object {
"useRbacForRequest": [Function],
},
diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts
index d0403c0f170ea..1873ca42324c0 100644
--- a/x-pack/plugins/security/server/plugin.ts
+++ b/x-pack/plugins/security/server/plugin.ts
@@ -328,6 +328,8 @@ export class SecurityPlugin
checkPrivilegesWithRequest: this.authorizationSetup.checkPrivilegesWithRequest,
checkPrivilegesDynamicallyWithRequest: this.authorizationSetup
.checkPrivilegesDynamicallyWithRequest,
+ checkSavedObjectsPrivilegesWithRequest: this.authorizationSetup
+ .checkSavedObjectsPrivilegesWithRequest,
mode: this.authorizationSetup.mode,
},
@@ -386,6 +388,8 @@ export class SecurityPlugin
checkPrivilegesWithRequest: this.authorizationSetup!.checkPrivilegesWithRequest,
checkPrivilegesDynamicallyWithRequest: this.authorizationSetup!
.checkPrivilegesDynamicallyWithRequest,
+ checkSavedObjectsPrivilegesWithRequest: this.authorizationSetup!
+ .checkSavedObjectsPrivilegesWithRequest,
mode: this.authorizationSetup!.mode,
},
});
diff --git a/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts b/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts
index 531b547a1f275..b9ab7cef7f15b 100644
--- a/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts
+++ b/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts
@@ -11,7 +11,11 @@ import type { CheckSavedObjectsPrivileges } from '../authorization';
import { Actions } from '../authorization';
import type { CheckPrivilegesResponse } from '../authorization/types';
import type { EnsureAuthorizedResult } from './ensure_authorized';
-import { ensureAuthorized, getEnsureAuthorizedActionResult } from './ensure_authorized';
+import {
+ ensureAuthorized,
+ getEnsureAuthorizedActionResult,
+ isAuthorizedForObjectInAllSpaces,
+} from './ensure_authorized';
describe('ensureAuthorized', () => {
function setupDependencies() {
@@ -224,3 +228,46 @@ describe('getEnsureAuthorizedActionResult', () => {
expect(result).toEqual({ authorizedSpaces: [] });
});
});
+
+describe('isAuthorizedForObjectInAllSpaces', () => {
+ const typeActionMap: EnsureAuthorizedResult<'action'>['typeActionMap'] = new Map([
+ ['type-1', { action: { authorizedSpaces: [], isGloballyAuthorized: true } }],
+ ['type-2', { action: { authorizedSpaces: ['space-1', 'space-2'] } }],
+ ['type-3', { action: { authorizedSpaces: [] } }],
+ // type-4 is not present in the results
+ ]);
+
+ test('returns true if the user is authorized for the type in the given spaces', () => {
+ const type1Result = isAuthorizedForObjectInAllSpaces('type-1', 'action', typeActionMap, [
+ 'space-1',
+ 'space-2',
+ 'space-3',
+ ]);
+ expect(type1Result).toBe(true);
+
+ const type2Result = isAuthorizedForObjectInAllSpaces('type-2', 'action', typeActionMap, [
+ 'space-1',
+ 'space-2',
+ ]);
+ expect(type2Result).toBe(true);
+ });
+
+ test('returns false if the user is not authorized for the type in the given spaces', () => {
+ const type2Result = isAuthorizedForObjectInAllSpaces('type-2', 'action', typeActionMap, [
+ 'space-1',
+ 'space-2',
+ 'space-3', // the user is not authorized for this type and action in space-3
+ ]);
+ expect(type2Result).toBe(false);
+
+ const type3Result = isAuthorizedForObjectInAllSpaces('type-3', 'action', typeActionMap, [
+ 'space-1', // the user is not authorized for this type and action in any space
+ ]);
+ expect(type3Result).toBe(false);
+
+ const type4Result = isAuthorizedForObjectInAllSpaces('type-4', 'action', typeActionMap, [
+ 'space-1', // the user is not authorized for this type and action in any space
+ ]);
+ expect(type4Result).toBe(false);
+ });
+});
diff --git a/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts b/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts
index 0ce7b5f78f13b..c3457e75f9644 100644
--- a/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts
+++ b/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts
@@ -135,6 +135,29 @@ export function getEnsureAuthorizedActionResult(
return record[action] ?? { authorizedSpaces: [] };
}
+/**
+ * Helper function that, given an `EnsureAuthorizedResult`, ensures that the user is authorized to perform a given action for the given
+ * object type in the given spaces.
+ *
+ * @param {string} objectType the object type to check.
+ * @param {T} action the action to check.
+ * @param {EnsureAuthorizedResult['typeActionMap']} typeActionMap the typeActionMap from an EnsureAuthorizedResult.
+ * @param {string[]} spacesToAuthorizeFor the spaces to check.
+ */
+export function isAuthorizedForObjectInAllSpaces(
+ objectType: string,
+ action: T,
+ typeActionMap: EnsureAuthorizedResult['typeActionMap'],
+ spacesToAuthorizeFor: string[]
+) {
+ const actionResult = getEnsureAuthorizedActionResult(objectType, action, typeActionMap);
+ const { authorizedSpaces, isGloballyAuthorized } = actionResult;
+ const authorizedSpacesSet = new Set(authorizedSpaces);
+ return (
+ isGloballyAuthorized || spacesToAuthorizeFor.every((space) => authorizedSpacesSet.has(space))
+ );
+}
+
async function checkPrivileges(
deps: EnsureAuthorizedDependencies,
actions: string | string[],
diff --git a/x-pack/plugins/security/server/saved_objects/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts
index 364f639e9e9a3..b291fa86bbf56 100644
--- a/x-pack/plugins/security/server/saved_objects/index.ts
+++ b/x-pack/plugins/security/server/saved_objects/index.ts
@@ -24,6 +24,19 @@ interface SetupSavedObjectsParams {
getSpacesService(): SpacesService | undefined;
}
+export type {
+ EnsureAuthorizedDependencies,
+ EnsureAuthorizedOptions,
+ EnsureAuthorizedResult,
+ EnsureAuthorizedActionResult,
+} from './ensure_authorized';
+
+export {
+ ensureAuthorized,
+ getEnsureAuthorizedActionResult,
+ isAuthorizedForObjectInAllSpaces,
+} from './ensure_authorized';
+
export function setupSavedObjects({
legacyAuditLogger,
audit,
diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
index ef3dcac4c064b..a3bd215211983 100644
--- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
+++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts
@@ -41,7 +41,11 @@ import type {
EnsureAuthorizedOptions,
EnsureAuthorizedResult,
} from './ensure_authorized';
-import { ensureAuthorized, getEnsureAuthorizedActionResult } from './ensure_authorized';
+import {
+ ensureAuthorized,
+ getEnsureAuthorizedActionResult,
+ isAuthorizedForObjectInAllSpaces,
+} from './ensure_authorized';
interface SecureSavedObjectsClientWrapperOptions {
actions: Actions;
@@ -1071,20 +1075,6 @@ function namespaceComparator(a: string, b: string) {
return A > B ? 1 : A < B ? -1 : 0;
}
-function isAuthorizedForObjectInAllSpaces(
- objectType: string,
- action: T,
- typeActionMap: EnsureAuthorizedResult['typeActionMap'],
- spacesToAuthorizeFor: string[]
-) {
- const actionResult = getEnsureAuthorizedActionResult(objectType, action, typeActionMap);
- const { authorizedSpaces, isGloballyAuthorized } = actionResult;
- const authorizedSpacesSet = new Set(authorizedSpaces);
- return (
- isGloballyAuthorized || spacesToAuthorizeFor.every((space) => authorizedSpacesSet.has(space))
- );
-}
-
function getRedactedSpaces(
objectType: string,
action: T,
diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.mocks.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.mocks.ts
new file mode 100644
index 0000000000000..02bd9971f28b8
--- /dev/null
+++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.mocks.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { ensureAuthorized } from '../saved_objects';
+
+export const mockEnsureAuthorized = jest.fn() as jest.MockedFunction;
+
+jest.mock('../saved_objects', () => {
+ return {
+ ...jest.requireActual('../saved_objects'),
+ ensureAuthorized: mockEnsureAuthorized,
+ };
+});
diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts
index bc7cb727edd80..20a524251bd4a 100644
--- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts
+++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts
@@ -5,15 +5,17 @@
* 2.0.
*/
+import { mockEnsureAuthorized } from './secure_spaces_client_wrapper.test.mocks';
+
import { deepFreeze } from '@kbn/std';
-import type { EcsEventOutcome } from 'src/core/server';
+import type { EcsEventOutcome, SavedObjectsClientContract } from 'src/core/server';
import { SavedObjectsErrorHelpers } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
-import type { GetAllSpacesPurpose, Space } from '../../../spaces/server';
+import type { GetAllSpacesPurpose, LegacyUrlAliasTarget, Space } from '../../../spaces/server';
import { spacesClientMock } from '../../../spaces/server/mocks';
import type { AuditEvent, AuditLogger } from '../audit';
-import { SpaceAuditAction } from '../audit';
+import { SavedObjectAction, SpaceAuditAction } from '../audit';
import { auditServiceMock } from '../audit/index.mock';
import type {
AuthorizationServiceSetup,
@@ -22,7 +24,11 @@ import type {
import { authorizationMock } from '../authorization/index.mock';
import type { CheckPrivilegesResponse } from '../authorization/types';
import type { LegacySpacesAuditLogger } from './legacy_audit_logger';
-import { SecureSpacesClientWrapper } from './secure_spaces_client_wrapper';
+import {
+ getAliasId,
+ LEGACY_URL_ALIAS_TYPE,
+ SecureSpacesClientWrapper,
+} from './secure_spaces_client_wrapper';
interface Opts {
securityEnabled?: boolean;
@@ -71,12 +77,20 @@ const setup = ({ securityEnabled = false }: Opts = {}) => {
const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest());
const request = httpServerMock.createKibanaRequest();
+
+ const forbiddenError = new Error('Mock ForbiddenError');
+ const errors = ({
+ decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError),
+ // other errors exist but are not needed for these test cases
+ } as unknown) as jest.Mocked;
+
const wrapper = new SecureSpacesClientWrapper(
baseClient,
request,
authorization,
auditLogger,
- legacyAuditLogger
+ legacyAuditLogger,
+ errors
);
return {
authorization,
@@ -85,6 +99,7 @@ const setup = ({ securityEnabled = false }: Opts = {}) => {
baseClient,
auditLogger,
legacyAuditLogger,
+ forbiddenError,
};
};
@@ -160,6 +175,10 @@ const expectAuditEvent = (
);
};
+beforeEach(() => {
+ mockEnsureAuthorized.mockReset();
+});
+
describe('SecureSpacesClientWrapper', () => {
describe('#getAll', () => {
const savedObjects = [
@@ -747,4 +766,99 @@ describe('SecureSpacesClientWrapper', () => {
});
});
});
+
+ describe('#disableLegacyUrlAliases', () => {
+ const alias1 = { targetSpace: 'space-1', targetType: 'type-1', sourceId: 'id' };
+ const alias2 = { targetSpace: 'space-2', targetType: 'type-2', sourceId: 'id' };
+
+ function expectAuditEvents(
+ auditLogger: AuditLogger,
+ aliases: LegacyUrlAliasTarget[],
+ action: EcsEventOutcome
+ ) {
+ aliases.forEach((alias) => {
+ expectAuditEvent(auditLogger, SavedObjectAction.UPDATE, action, {
+ type: LEGACY_URL_ALIAS_TYPE,
+ id: getAliasId(alias),
+ });
+ });
+ }
+
+ function expectAuthorizationCheck(targetTypes: string[], targetSpaces: string[]) {
+ expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1);
+ expect(mockEnsureAuthorized).toHaveBeenCalledWith(
+ expect.any(Object), // dependencies
+ targetTypes, // unique types of the alias targets
+ ['bulk_update'], // actions
+ targetSpaces, // unique spaces of the alias targets
+ { requireFullAuthorization: false }
+ );
+ }
+
+ describe('when security is not enabled', () => {
+ const securityEnabled = false;
+
+ it('delegates to base client without checking authorization', async () => {
+ const { wrapper, baseClient, auditLogger } = setup({ securityEnabled });
+ const aliases = [alias1];
+ await wrapper.disableLegacyUrlAliases(aliases);
+
+ expect(mockEnsureAuthorized).not.toHaveBeenCalled();
+ expectAuditEvents(auditLogger, aliases, 'unknown');
+ expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledTimes(1);
+ expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledWith(aliases);
+ });
+ });
+
+ describe('when security is enabled', () => {
+ const securityEnabled = true;
+
+ it('re-throws the error if the authorization check fails', async () => {
+ const error = new Error('Oh no!');
+ mockEnsureAuthorized.mockRejectedValue(error);
+ const { wrapper, baseClient, auditLogger } = setup({ securityEnabled });
+ const aliases = [alias1, alias2];
+ await expect(() => wrapper.disableLegacyUrlAliases(aliases)).rejects.toThrow(error);
+
+ expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']);
+ expectAuditEvents(auditLogger, aliases, 'failure');
+ expect(baseClient.disableLegacyUrlAliases).not.toHaveBeenCalled();
+ });
+
+ it('throws a forbidden error when unauthorized', async () => {
+ mockEnsureAuthorized.mockResolvedValue({
+ status: 'partially_authorized',
+ typeActionMap: new Map()
+ .set('type-1', { bulk_update: { authorizedSpaces: ['space-1'] } })
+ .set('type-2', { bulk_update: { authorizedSpaces: ['space-1'] } }), // the user is not authorized to bulkUpdate type-2 in space-2, so this will throw a forbidden error
+ });
+ const { wrapper, baseClient, auditLogger, forbiddenError } = setup({ securityEnabled });
+ const aliases = [alias1, alias2];
+ await expect(() => wrapper.disableLegacyUrlAliases(aliases)).rejects.toThrow(
+ forbiddenError
+ );
+
+ expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']);
+ expectAuditEvents(auditLogger, aliases, 'failure');
+ expect(baseClient.disableLegacyUrlAliases).not.toHaveBeenCalled();
+ });
+
+ it('updates the legacy URL aliases when authorized', async () => {
+ mockEnsureAuthorized.mockResolvedValue({
+ status: 'partially_authorized',
+ typeActionMap: new Map()
+ .set('type-1', { bulk_update: { authorizedSpaces: ['space-1'] } })
+ .set('type-2', { bulk_update: { authorizedSpaces: ['space-2'] } }),
+ });
+ const { wrapper, baseClient, auditLogger } = setup({ securityEnabled });
+ const aliases = [alias1, alias2];
+ await wrapper.disableLegacyUrlAliases(aliases);
+
+ expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']);
+ expectAuditEvents(auditLogger, aliases, 'unknown');
+ expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledTimes(1);
+ expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledWith(aliases);
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts
index ab882570ac630..f3d66ac0381eb 100644
--- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts
+++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts
@@ -7,19 +7,22 @@
import Boom from '@hapi/boom';
-import type { KibanaRequest } from 'src/core/server';
+import type { KibanaRequest, SavedObjectsClientContract } from 'src/core/server';
import type {
GetAllSpacesOptions,
GetAllSpacesPurpose,
GetSpaceResult,
ISpacesClient,
+ LegacyUrlAliasTarget,
Space,
} from '../../../spaces/server';
import type { AuditLogger } from '../audit';
-import { SpaceAuditAction, spaceAuditEvent } from '../audit';
+import { SavedObjectAction, savedObjectEvent, SpaceAuditAction, spaceAuditEvent } from '../audit';
import type { AuthorizationServiceSetup } from '../authorization';
import type { SecurityPluginSetup } from '../plugin';
+import type { EnsureAuthorizedDependencies, EnsureAuthorizedOptions } from '../saved_objects';
+import { ensureAuthorized, isAuthorizedForObjectInAllSpaces } from '../saved_objects';
import type { LegacySpacesAuditLogger } from './legacy_audit_logger';
const PURPOSE_PRIVILEGE_MAP: Record<
@@ -38,6 +41,9 @@ const PURPOSE_PRIVILEGE_MAP: Record<
],
};
+/** @internal */
+export const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias';
+
export class SecureSpacesClientWrapper implements ISpacesClient {
private readonly useRbac = this.authorization.mode.useRbacForRequest(this.request);
@@ -46,7 +52,8 @@ export class SecureSpacesClientWrapper implements ISpacesClient {
private readonly request: KibanaRequest,
private readonly authorization: AuthorizationServiceSetup,
private readonly auditLogger: AuditLogger,
- private readonly legacyAuditLogger: LegacySpacesAuditLogger
+ private readonly legacyAuditLogger: LegacySpacesAuditLogger,
+ private readonly errors: SavedObjectsClientContract['errors']
) {}
public async getAll({
@@ -277,6 +284,85 @@ export class SecureSpacesClientWrapper implements ISpacesClient {
return this.spacesClient.delete(id);
}
+ public async disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]) {
+ if (this.useRbac) {
+ try {
+ const [uniqueSpaces, uniqueTypes, typesAndSpacesMap] = aliases.reduce(
+ ([spaces, types, typesAndSpaces], { targetSpace, targetType }) => {
+ const spacesForType = typesAndSpaces.get(targetType) ?? new Set();
+ return [
+ spaces.add(targetSpace),
+ types.add(targetType),
+ typesAndSpaces.set(targetType, spacesForType.add(targetSpace)),
+ ];
+ },
+ [new Set(), new Set(), new Map>()]
+ );
+
+ const action = 'bulk_update';
+ const { typeActionMap } = await this.ensureAuthorizedForSavedObjects(
+ Array.from(uniqueTypes),
+ [action],
+ Array.from(uniqueSpaces),
+ { requireFullAuthorization: false }
+ );
+ const unauthorizedTypes = new Set();
+ for (const type of uniqueTypes) {
+ const spaces = Array.from(typesAndSpacesMap.get(type)!);
+ if (!isAuthorizedForObjectInAllSpaces(type, action, typeActionMap, spaces)) {
+ unauthorizedTypes.add(type);
+ }
+ }
+ if (unauthorizedTypes.size > 0) {
+ const targetTypes = Array.from(unauthorizedTypes).sort().join(',');
+ const msg = `Unable to disable aliases for ${targetTypes}`;
+ throw this.errors.decorateForbiddenError(new Error(msg));
+ }
+ } catch (error) {
+ aliases.forEach((alias) => {
+ const id = getAliasId(alias);
+ this.auditLogger.log(
+ savedObjectEvent({
+ action: SavedObjectAction.UPDATE,
+ savedObject: { type: LEGACY_URL_ALIAS_TYPE, id },
+ error,
+ })
+ );
+ });
+ throw error;
+ }
+ }
+
+ aliases.forEach((alias) => {
+ const id = getAliasId(alias);
+ this.auditLogger.log(
+ savedObjectEvent({
+ action: SavedObjectAction.UPDATE,
+ outcome: 'unknown',
+ savedObject: { type: LEGACY_URL_ALIAS_TYPE, id },
+ })
+ );
+ });
+
+ return this.spacesClient.disableLegacyUrlAliases(aliases);
+ }
+
+ private async ensureAuthorizedForSavedObjects(
+ types: string[],
+ actions: T[],
+ namespaces: string[],
+ options?: EnsureAuthorizedOptions
+ ) {
+ const ensureAuthorizedDependencies: EnsureAuthorizedDependencies = {
+ actions: this.authorization.actions,
+ errors: this.errors,
+ checkSavedObjectsPrivilegesAsCurrentUser: this.authorization.checkSavedObjectsPrivilegesWithRequest(
+ this.request
+ ),
+ };
+ return ensureAuthorized(ensureAuthorizedDependencies, types, actions, namespaces, options);
+ }
+
private async ensureAuthorizedGlobally(action: string, method: string, forbiddenMessage: string) {
const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request);
const { username, hasAllRequested } = await checkPrivileges.globally({ kibana: action });
@@ -312,3 +398,8 @@ export class SecureSpacesClientWrapper implements ISpacesClient {
return value !== null;
}
}
+
+/** @internal This is only exported for testing purposes. */
+export function getAliasId({ targetSpace, targetType, sourceId }: LegacyUrlAliasTarget) {
+ return `${targetSpace}:${targetType}:${sourceId}`;
+}
diff --git a/x-pack/plugins/security/server/spaces/setup_spaces_client.ts b/x-pack/plugins/security/server/spaces/setup_spaces_client.ts
index 2344d3e8c69d6..b518a1cff2c0f 100644
--- a/x-pack/plugins/security/server/spaces/setup_spaces_client.ts
+++ b/x-pack/plugins/security/server/spaces/setup_spaces_client.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
+import { SavedObjectsClient } from '../../../../../src/core/server';
import type { SpacesPluginSetup } from '../../../spaces/server';
import type { AuditServiceSetup } from '../audit';
import type { AuthorizationServiceSetup } from '../authorization';
@@ -39,7 +40,8 @@ export const setupSpacesClient = ({ audit, authz, spaces }: Deps) => {
request,
authz,
audit.asScoped(request),
- spacesAuditLogger
+ spacesAuditLogger,
+ SavedObjectsClient.errors
)
);
};
diff --git a/x-pack/plugins/spaces/common/index.ts b/x-pack/plugins/spaces/common/index.ts
index 9935d8055ec30..003a0c068a166 100644
--- a/x-pack/plugins/spaces/common/index.ts
+++ b/x-pack/plugins/spaces/common/index.ts
@@ -8,4 +8,9 @@
export { isReservedSpace } from './is_reserved_space';
export { MAX_SPACE_INITIALS, SPACE_SEARCH_COUNT_THRESHOLD, ENTER_SPACE_PATH } from './constants';
export { addSpaceIdToPath, getSpaceIdFromPath } from './lib/spaces_url_parser';
-export type { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from './types';
+export type {
+ GetAllSpacesOptions,
+ GetAllSpacesPurpose,
+ GetSpaceResult,
+ LegacyUrlAliasTarget,
+} from './types';
diff --git a/x-pack/plugins/spaces/common/types.ts b/x-pack/plugins/spaces/common/types.ts
index 866d29bf64d5b..55bd1c137f8cf 100644
--- a/x-pack/plugins/spaces/common/types.ts
+++ b/x-pack/plugins/spaces/common/types.ts
@@ -50,3 +50,21 @@ export interface GetSpaceResult extends Space {
*/
authorizedPurposes?: Record;
}
+
+/**
+ * Client interface for interacting with legacy URL aliases.
+ */
+export interface LegacyUrlAliasTarget {
+ /**
+ * The namespace that the object existed in when it was converted.
+ */
+ targetSpace: string;
+ /**
+ * The type of the object when it was converted.
+ */
+ targetType: string;
+ /**
+ * The original ID of the object, before it was converted.
+ */
+ sourceId: string;
+}
diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx
index e1ecc06935791..2f96646844a35 100644
--- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx
+++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx
@@ -14,6 +14,7 @@ import React, { lazy, Suspense } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import type { Space } from 'src/plugins/spaces_oss/common';
+import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../common';
import { getSpaceAvatarComponent } from '../../space_avatar';
// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana.
@@ -83,7 +84,7 @@ export const SelectableSpacesControl = (props: Props) => {
className: 'spcCopyToSpace__spacesList',
'data-test-subj': 'cts-form-space-selector',
}}
- searchable={options.length > 6}
+ searchable={options.length > SPACE_SEARCH_COUNT_THRESHOLD}
>
{(list, search) => {
return (
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/alias_table.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/alias_table.tsx
new file mode 100644
index 0000000000000..2a2f2470e0199
--- /dev/null
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/alias_table.tsx
@@ -0,0 +1,108 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { EuiTableComputedColumnType, Pagination } from '@elastic/eui';
+import {
+ EuiCallOut,
+ EuiFlexItem,
+ EuiInMemoryTable,
+ EuiLoadingSpinner,
+ EuiSpacer,
+} from '@elastic/eui';
+import type { FunctionComponent } from 'react';
+import React, { lazy, Suspense, useMemo, useState } from 'react';
+
+import { FormattedMessage } from '@kbn/i18n/react';
+
+import { getSpaceAvatarComponent } from '../../space_avatar';
+import type { ShareToSpaceTarget } from '../../types';
+import type { InternalLegacyUrlAliasTarget } from './types';
+
+// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana.
+const LazySpaceAvatar = lazy(() =>
+ getSpaceAvatarComponent().then((component) => ({ default: component }))
+);
+
+interface Props {
+ spaces: ShareToSpaceTarget[];
+ aliasesToDisable: InternalLegacyUrlAliasTarget[];
+}
+
+export const AliasTable: FunctionComponent = ({ spaces, aliasesToDisable }) => {
+ const [pageIndex, setPageIndex] = useState(0);
+ const [pageSize, setPageSize] = useState(5);
+
+ const spacesMap = useMemo(
+ () =>
+ spaces.reduce(
+ (acc, space) => acc.set(space.id, space),
+ new Map()
+ ),
+ [spaces]
+ );
+ const filteredAliasesToDisable = useMemo(
+ () => aliasesToDisable.filter(({ spaceExists }) => spaceExists),
+ [aliasesToDisable]
+ );
+ const aliasesToDisableCount = filteredAliasesToDisable.length;
+ const pagination: Pagination = {
+ pageIndex,
+ pageSize,
+ totalItemCount: aliasesToDisableCount,
+ pageSizeOptions: [5, 10, 15, 20],
+ };
+
+ return (
+ <>
+
+ }
+ color="warning"
+ >
+
+
+
+
+
+
+ }>
+ {
+ const space = spacesMap.get(targetSpace)!; // it's safe to use ! here because we filtered only for aliases that are in spaces that exist
+ return ; // the whole table is wrapped in a Suspense
+ },
+ sortable: ({ targetSpace }) => targetSpace,
+ } as EuiTableComputedColumnType,
+ ]}
+ sorting={true}
+ pagination={pagination}
+ onTableChange={({ page: { index, size } }) => {
+ setPageIndex(index);
+ setPageSize(size);
+ }}
+ tableLayout="auto"
+ />
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/relatives_footer.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/relatives_footer.tsx
new file mode 100644
index 0000000000000..ea3f29724e0d5
--- /dev/null
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/relatives_footer.tsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { EuiHorizontalRule, EuiText } from '@elastic/eui';
+import React, { useMemo } from 'react';
+
+import { FormattedMessage } from '@kbn/i18n/react';
+import type { SavedObjectReferenceWithContext } from 'src/core/public';
+import type { ShareToSpaceSavedObjectTarget } from 'src/plugins/spaces_oss/public';
+
+interface Props {
+ savedObjectTarget: ShareToSpaceSavedObjectTarget;
+ referenceGraph: SavedObjectReferenceWithContext[];
+ isDisabled: boolean;
+}
+
+export const RelativesFooter = (props: Props) => {
+ const { savedObjectTarget, referenceGraph, isDisabled } = props;
+
+ const relativesCount = useMemo(() => {
+ const { type, id } = savedObjectTarget;
+ return referenceGraph.filter(
+ (x) => (x.type !== type || x.id !== id) && x.spaces.length > 0 && !x.isMissing
+ ).length;
+ }, [savedObjectTarget, referenceGraph]);
+
+ if (relativesCount > 0) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+ return null;
+};
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx
index 876c8d027b2b4..fad819d35e18a 100644
--- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx
@@ -17,7 +17,6 @@ import {
EuiLink,
EuiLoadingSpinner,
EuiSelectable,
- EuiSpacer,
EuiText,
} from '@elastic/eui';
import React, { lazy, Suspense } from 'react';
@@ -25,6 +24,7 @@ import React, { lazy, Suspense } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
+import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../common';
import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants';
import { DocumentationLinksService } from '../../lib';
import { getSpaceAvatarComponent } from '../../space_avatar';
@@ -106,10 +106,14 @@ export const SelectableSpacesControl = (props: Props) => {
.sort(createSpacesComparator(activeSpaceId))
.map((space) => {
const checked = selectedSpaceIds.includes(space.id);
- const additionalProps = getAdditionalProps(space, activeSpaceId, checked);
+ const { isAvatarDisabled, ...additionalProps } = getAdditionalProps(
+ space,
+ activeSpaceId,
+ checked
+ );
return {
label: space.name,
- prepend: , // wrapped in a Suspense below
+ prepend: , // wrapped in a Suspense below
checked: checked ? 'on' : undefined,
['data-space-id']: space.id,
['data-test-subj']: `sts-space-selector-row-${space.id}`,
@@ -140,8 +144,7 @@ export const SelectableSpacesControl = (props: Props) => {
docLinks!
).getKibanaPrivilegesDocUrl();
return (
- <>
-
+
{
}}
/>
- >
+
);
};
const getNoSpacesAvailable = () => {
if (enableCreateNewSpaceLink && spaces.length < 2) {
- return ;
+ return (
+
+
+
+ );
}
return null;
};
@@ -188,46 +195,52 @@ export const SelectableSpacesControl = (props: Props) => {
);
const hiddenSpaces = hiddenCount ? {hiddenSpacesLabel} : null;
return (
-
-
- {selectedSpacesLabel}
-
- {hiddenSpaces}
-
- }
- fullWidth
- >
- <>
- }>
- updateSelectedSpaces(newOptions as SpaceOption[])}
- listProps={{
- bordered: true,
- rowHeight: ROW_HEIGHT,
- className: 'spcShareToSpace__spacesList',
- 'data-test-subj': 'sts-form-space-selector',
- }}
- height={ROW_HEIGHT * 3.5}
- searchable={options.length > 6}
- >
- {(list, search) => {
- return (
- <>
- {search}
- {list}
- >
- );
- }}
-
-
+ <>
+
+
+ {selectedSpacesLabel}
+
+ {hiddenSpaces}
+
+ }
+ fullWidth
+ >
+ <>>
+
+
+
+
+ }>
+ updateSelectedSpaces(newOptions as SpaceOption[])}
+ listProps={{
+ bordered: true,
+ rowHeight: ROW_HEIGHT,
+ className: 'spcShareToSpace__spacesList',
+ 'data-test-subj': 'sts-form-space-selector',
+ }}
+ height="full"
+ searchable={options.length > SPACE_SEARCH_COUNT_THRESHOLD}
+ >
+ {(list, search) => {
+ return (
+ <>
+ {search}
+ {list}
+ >
+ );
+ }}
+
+
+
{getUnknownSpacesLabel()}
{getNoSpacesAvailable()}
- >
-
+
+ >
);
};
@@ -260,8 +273,10 @@ function getAdditionalProps(
if (space.isFeatureDisabled) {
return {
append: APPEND_FEATURE_IS_DISABLED,
+ isAvatarDisabled: true,
};
}
+ return {};
}
/**
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.scss b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.scss
deleted file mode 100644
index 3baa21f68d4f3..0000000000000
--- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.euiCheckableCard__children {
- width: 100%; // required to expand the contents of EuiCheckableCard to the full width
-}
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx
index df8d72f7a59de..7151f72583d6a 100644
--- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx
@@ -5,11 +5,9 @@
* 2.0.
*/
-import './share_mode_control.scss';
-
import {
+ EuiButtonGroup,
EuiCallOut,
- EuiCheckableCard,
EuiFlexGroup,
EuiFlexItem,
EuiIconTip,
@@ -40,36 +38,27 @@ interface Props {
enableSpaceAgnosticBehavior: boolean;
}
-function createLabel({
- title,
- text,
- disabled,
- tooltip,
-}: {
- title: string;
- text: string;
- disabled: boolean;
- tooltip?: string;
-}) {
- return (
- <>
-
-
- {title}
-
- {tooltip && (
-
-
-
- )}
-
-
-
- {text}
-
- >
- );
-}
+const buttonGroupLegend = i18n.translate(
+ 'xpack.spaces.shareToSpace.shareModeControl.buttonGroupLegend',
+ { defaultMessage: 'Choose how this is shared' }
+);
+
+const shareToAllSpacesId = 'shareToAllSpacesId';
+const shareToAllSpacesButtonLabel = i18n.translate(
+ 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.buttonLabel',
+ { defaultMessage: 'All spaces' }
+);
+
+const shareToExplicitSpacesId = 'shareToExplicitSpacesId';
+const shareToExplicitSpacesButtonLabel = i18n.translate(
+ 'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.buttonLabel',
+ { defaultMessage: 'Select spaces' }
+);
+
+const cannotChangeTooltip = i18n.translate(
+ 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotChangeTooltip',
+ { defaultMessage: 'You need additional privileges to change this option.' }
+);
export const ShareModeControl = (props: Props) => {
const {
@@ -90,50 +79,9 @@ export const ShareModeControl = (props: Props) => {
const { selectedSpaceIds } = shareOptions;
const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID);
- const shareToAllSpaces = {
- id: 'shareToAllSpaces',
- title: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title', {
- defaultMessage: 'All spaces',
- }),
- text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text', {
- defaultMessage: 'Make {objectNoun} available in all current and future spaces.',
- values: { objectNoun },
- }),
- ...(!canShareToAllSpaces && {
- tooltip: isGlobalControlChecked
- ? i18n.translate(
- 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip',
- { defaultMessage: 'You need additional privileges to change this option.' }
- )
- : i18n.translate(
- 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip',
- { defaultMessage: 'You need additional privileges to use this option.' }
- ),
- }),
- disabled: !canShareToAllSpaces,
- };
- const shareToExplicitSpaces = {
- id: 'shareToExplicitSpaces',
- title: i18n.translate(
- 'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title',
- { defaultMessage: 'Select spaces' }
- ),
- text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text', {
- defaultMessage: 'Make {objectNoun} available in selected spaces only.',
- values: { objectNoun },
- }),
- disabled: !canShareToAllSpaces && isGlobalControlChecked,
- };
-
- const toggleShareOption = (allSpaces: boolean) => {
- const updatedSpaceIds = allSpaces
- ? [ALL_SPACES_ID, ...selectedSpaceIds]
- : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID);
- onChange(updatedSpaceIds);
- };
const getPrivilegeWarning = () => {
- if (!shareToExplicitSpaces.disabled) {
+ if (canShareToAllSpaces || !isGlobalControlChecked) {
return null;
}
@@ -180,13 +128,66 @@ export const ShareModeControl = (props: Props) => {
<>
{getPrivilegeWarning()}
- toggleShareOption(false)}
- disabled={shareToExplicitSpaces.disabled}
- >
+ {
+ const updatedSpaceIds =
+ optionId === shareToAllSpacesId
+ ? [ALL_SPACES_ID, ...selectedSpaceIds]
+ : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID);
+ onChange(updatedSpaceIds);
+ }}
+ legend={buttonGroupLegend}
+ color="secondary"
+ isFullWidth={true}
+ isDisabled={!canShareToAllSpaces}
+ />
+
+
+
+
+
+
+
+ {isGlobalControlChecked
+ ? i18n.translate(
+ 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text',
+ {
+ defaultMessage:
+ 'Make {objectNoun} available in all current and future spaces.',
+ values: { objectNoun },
+ }
+ )
+ : i18n.translate(
+ 'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text',
+ {
+ defaultMessage: 'Make {objectNoun} available in selected spaces only.',
+ values: { objectNoun },
+ }
+ )}
+
+
+ {!canShareToAllSpaces && (
+
+
+
+ )}
+
+
+
+
+
+
{
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
/>
-
-
- toggleShareOption(true)}
- disabled={shareToAllSpaces.disabled}
- />
+
>
);
};
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.scss b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.scss
new file mode 100644
index 0000000000000..5f5014c4a82f3
--- /dev/null
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.scss
@@ -0,0 +1,3 @@
+.spcShareToSpace__flyoutBodyWrapper {
+ padding: $euiSizeL;
+}
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx
index 4ec90b7e3826b..f02cae7674058 100644
--- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx
@@ -5,20 +5,14 @@
* 2.0.
*/
-import type { EuiCheckableCardProps } from '@elastic/eui';
-import {
- EuiCallOut,
- EuiCheckableCard,
- EuiIconTip,
- EuiLoadingSpinner,
- EuiSelectable,
-} from '@elastic/eui';
+import { EuiCallOut, EuiIconTip, EuiLoadingSpinner, EuiSelectable } from '@elastic/eui';
import Boom from '@hapi/boom';
import { act } from '@testing-library/react';
import type { ReactWrapper } from 'enzyme';
import React from 'react';
import { findTestSubject, mountWithIntl, nextTick } from '@kbn/test/jest';
+import type { SavedObjectReferenceWithContext } from 'src/core/public';
import { coreMock } from 'src/core/public/mocks';
import type { Space } from 'src/plugins/spaces_oss/common';
@@ -26,7 +20,9 @@ import { ALL_SPACES_ID } from '../../../common/constants';
import { CopyToSpaceFlyoutInternal } from '../../copy_saved_objects_to_space/components/copy_to_space_flyout_internal';
import { getSpacesContextProviderWrapper } from '../../spaces_context';
import { spacesManagerMock } from '../../spaces_manager/mocks';
+import { AliasTable } from './alias_table';
import { NoSpacesAvailable } from './no_spaces_available';
+import { RelativesFooter } from './relatives_footer';
import { SelectableSpacesControl } from './selectable_spaces_control';
import { ShareModeControl } from './share_mode_control';
import { getShareToSpaceFlyoutComponent } from './share_to_space_flyout';
@@ -41,6 +37,7 @@ interface SetupOpts {
enableCreateNewSpaceLink?: boolean;
behaviorContext?: 'within-space' | 'outside-space';
mockFeatureId?: string; // optional feature ID to use for the SpacesContext
+ additionalShareableReferences?: SavedObjectReferenceWithContext[];
}
const setup = async (opts: SetupOpts = {}) => {
@@ -94,6 +91,19 @@ const setup = async (opts: SetupOpts = {}) => {
title: 'foo',
};
+ mockSpacesManager.getShareableReferences.mockResolvedValue({
+ objects: [
+ {
+ // this is the result for the saved object target; by default, it has no references
+ type: savedObjectToShare.type,
+ id: savedObjectToShare.id,
+ spaces: savedObjectToShare.namespaces,
+ inboundReferences: [],
+ },
+ ...(opts.additionalShareableReferences ?? []),
+ ],
+ });
+
const { getStartServices } = coreMock.createSetup();
const startServices = coreMock.createStart();
startServices.application.capabilities = {
@@ -138,6 +148,25 @@ const setup = async (opts: SetupOpts = {}) => {
return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare };
};
+function changeSpaceSelection(wrapper: ReactWrapper, selectedSpaces: string[]) {
+ // Using props callback instead of simulating clicks, because EuiSelectable uses a virtualized list, which isn't easily testable via test
+ // subjects
+ const spaceSelector = wrapper.find(SelectableSpacesControl);
+ act(() => {
+ spaceSelector.props().onChange(selectedSpaces);
+ });
+ wrapper.update();
+}
+
+async function clickButton(wrapper: ReactWrapper, button: 'continue' | 'save' | 'copy') {
+ const buttonNode = findTestSubject(wrapper, `sts-${button}-button`);
+ await act(async () => {
+ buttonNode.simulate('click');
+ await nextTick();
+ wrapper.update();
+ });
+}
+
describe('ShareToSpaceFlyout', () => {
it('waits for spaces to load', async () => {
const { wrapper } = await setup({ returnBeforeSpacesLoad: true });
@@ -212,12 +241,7 @@ describe('ShareToSpaceFlyout', () => {
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
- const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout
-
- await act(async () => {
- copyButton.simulate('click');
- await nextTick();
- });
+ await clickButton(wrapper, 'copy'); // this link is only present in the warning callout
wrapper.update();
expect(wrapper.find(CopyToSpaceFlyoutInternal)).toHaveLength(1);
@@ -288,20 +312,8 @@ describe('ShareToSpaceFlyout', () => {
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
- // Using props callback instead of simulating clicks,
- // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
- const spaceSelector = wrapper.find(SelectableSpacesControl);
- act(() => {
- spaceSelector.props().onChange(['space-2', 'space-3']);
- });
-
- const startButton = findTestSubject(wrapper, 'sts-initiate-button');
-
- await act(async () => {
- startButton.simulate('click');
- await nextTick();
- wrapper.update();
- });
+ changeSpaceSelection(wrapper, ['space-2', 'space-3']);
+ await clickButton(wrapper, 'save');
expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalled();
expect(mockToastNotifications.addError).toHaveBeenCalled();
@@ -320,21 +332,8 @@ describe('ShareToSpaceFlyout', () => {
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
- // Using props callback instead of simulating clicks,
- // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
- const spaceSelector = wrapper.find(SelectableSpacesControl);
-
- act(() => {
- spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']);
- });
-
- const startButton = findTestSubject(wrapper, 'sts-initiate-button');
-
- await act(async () => {
- startButton.simulate('click');
- await nextTick();
- wrapper.update();
- });
+ changeSpaceSelection(wrapper, ['space-1', 'space-2', 'space-3']);
+ await clickButton(wrapper, 'save');
const { type, id } = savedObjectToShare;
expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith(
@@ -361,21 +360,8 @@ describe('ShareToSpaceFlyout', () => {
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
- // Using props callback instead of simulating clicks,
- // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
- const spaceSelector = wrapper.find(SelectableSpacesControl);
-
- act(() => {
- spaceSelector.props().onChange([]);
- });
-
- const startButton = findTestSubject(wrapper, 'sts-initiate-button');
-
- await act(async () => {
- startButton.simulate('click');
- await nextTick();
- wrapper.update();
- });
+ changeSpaceSelection(wrapper, []);
+ await clickButton(wrapper, 'save');
const { type, id } = savedObjectToShare;
expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith(
@@ -402,21 +388,8 @@ describe('ShareToSpaceFlyout', () => {
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0);
expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0);
- // Using props callback instead of simulating clicks,
- // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects
- const spaceSelector = wrapper.find(SelectableSpacesControl);
-
- act(() => {
- spaceSelector.props().onChange(['space-2', 'space-3']);
- });
-
- const startButton = findTestSubject(wrapper, 'sts-initiate-button');
-
- await act(async () => {
- startButton.simulate('click');
- await nextTick();
- wrapper.update();
- });
+ changeSpaceSelection(wrapper, ['space-2', 'space-3']);
+ await clickButton(wrapper, 'save');
const { type, id } = savedObjectToShare;
expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith(
@@ -430,25 +403,13 @@ describe('ShareToSpaceFlyout', () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
- describe('correctly renders checkable cards', () => {
- function getCheckableCardProps(
- wrapper: ReactWrapper>
- ) {
- const iconTip = wrapper.find(EuiIconTip);
+ describe('correctly renders share mode control', () => {
+ function getDescriptionAndWarning(wrapper: ReactWrapper) {
+ const descriptionNode = findTestSubject(wrapper, 'share-mode-control-description');
+ const iconTipNode = wrapper.find(ShareModeControl).find(EuiIconTip);
return {
- checked: wrapper.prop('checked'),
- disabled: wrapper.prop('disabled'),
- ...(iconTip.length > 0 && { tooltip: iconTip.prop('content') as string }),
- };
- }
- function getCheckableCards(wrapper: ReactWrapper) {
- return {
- explicitSpacesCard: getCheckableCardProps(
- wrapper.find('#shareToExplicitSpaces').find(EuiCheckableCard)
- ),
- allSpacesCard: getCheckableCardProps(
- wrapper.find('#shareToAllSpaces').find(EuiCheckableCard)
- ),
+ description: descriptionNode.text(),
+ isPrivilegeTooltipDisplayed: iconTipNode.length > 0,
};
}
@@ -458,27 +419,23 @@ describe('ShareToSpaceFlyout', () => {
it('and the object is not shared to all spaces', async () => {
const namespaces = ['my-active-space'];
const { wrapper } = await setup({ canShareToAllSpaces, namespaces });
- const shareModeControl = wrapper.find(ShareModeControl);
- const checkableCards = getCheckableCards(shareModeControl);
+ const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper);
- expect(checkableCards).toEqual({
- explicitSpacesCard: { checked: true, disabled: false },
- allSpacesCard: { checked: false, disabled: false },
- });
- expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout
+ expect(description).toMatchInlineSnapshot(
+ `"Make object available in selected spaces only."`
+ );
+ expect(isPrivilegeTooltipDisplayed).toBe(false);
});
it('and the object is shared to all spaces', async () => {
const namespaces = [ALL_SPACES_ID];
const { wrapper } = await setup({ canShareToAllSpaces, namespaces });
- const shareModeControl = wrapper.find(ShareModeControl);
- const checkableCards = getCheckableCards(shareModeControl);
+ const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper);
- expect(checkableCards).toEqual({
- explicitSpacesCard: { checked: false, disabled: false },
- allSpacesCard: { checked: true, disabled: false },
- });
- expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout
+ expect(description).toMatchInlineSnapshot(
+ `"Make object available in all current and future spaces."`
+ );
+ expect(isPrivilegeTooltipDisplayed).toBe(false);
});
});
@@ -488,35 +445,23 @@ describe('ShareToSpaceFlyout', () => {
it('and the object is not shared to all spaces', async () => {
const namespaces = ['my-active-space'];
const { wrapper } = await setup({ canShareToAllSpaces, namespaces });
- const shareModeControl = wrapper.find(ShareModeControl);
- const checkableCards = getCheckableCards(shareModeControl);
-
- expect(checkableCards).toEqual({
- explicitSpacesCard: { checked: true, disabled: false },
- allSpacesCard: {
- checked: false,
- disabled: true,
- tooltip: 'You need additional privileges to use this option.',
- },
- });
- expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout
+ const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper);
+
+ expect(description).toMatchInlineSnapshot(
+ `"Make object available in selected spaces only."`
+ );
+ expect(isPrivilegeTooltipDisplayed).toBe(true);
});
it('and the object is shared to all spaces', async () => {
const namespaces = [ALL_SPACES_ID];
const { wrapper } = await setup({ canShareToAllSpaces, namespaces });
- const shareModeControl = wrapper.find(ShareModeControl);
- const checkableCards = getCheckableCards(shareModeControl);
+ const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper);
- expect(checkableCards).toEqual({
- explicitSpacesCard: { checked: false, disabled: true },
- allSpacesCard: {
- checked: true,
- disabled: true,
- tooltip: 'You need additional privileges to change this option.',
- },
- });
- expect(shareModeControl.find(EuiCallOut)).toHaveLength(1); // "Additional privileges required" callout
+ expect(description).toMatchInlineSnapshot(
+ `"Make object available in all current and future spaces."`
+ );
+ expect(isPrivilegeTooltipDisplayed).toBe(true);
});
});
});
@@ -714,4 +659,152 @@ describe('ShareToSpaceFlyout', () => {
});
});
});
+
+ describe('alias list', () => {
+ it('shows only aliases for spaces that exist', async () => {
+ const namespaces = ['my-active-space']; // the saved object's current namespaces
+ const { wrapper } = await setup({
+ namespaces,
+ additionalShareableReferences: [
+ // it doesn't matter if aliases are for the saved object target or for references; this is easier to mock
+ {
+ type: 'foo',
+ id: '1',
+ spaces: namespaces,
+ inboundReferences: [],
+ spacesWithMatchingAliases: ['space-1', 'some-space-that-does-not-exist'], // space-1 exists, it is mocked at the top
+ },
+ ],
+ });
+
+ changeSpaceSelection(wrapper, ['*']);
+ await clickButton(wrapper, 'continue');
+
+ const aliasTable = wrapper.find(AliasTable);
+ expect(aliasTable.prop('aliasesToDisable')).toEqual([
+ { targetType: 'foo', sourceId: '1', targetSpace: 'space-1', spaceExists: true },
+ {
+ // this alias is present, and it will be disabled, but it is not displayed in the table below due to the 'spaceExists' field
+ targetType: 'foo',
+ sourceId: '1',
+ targetSpace: 'some-space-that-does-not-exist',
+ spaceExists: false,
+ },
+ ]);
+ expect(aliasTable.find(EuiCallOut).text()).toMatchInlineSnapshot(
+ `"Legacy URL conflict1 legacy URL will be disabled."`
+ );
+ });
+
+ it('shows only aliases for selected spaces', async () => {
+ const namespaces = ['my-active-space']; // the saved object's current namespaces
+ const { wrapper } = await setup({
+ namespaces,
+ additionalShareableReferences: [
+ // it doesn't matter if aliases are for the saved object target or for references; this is easier to mock
+ {
+ type: 'foo',
+ id: '1',
+ spaces: namespaces,
+ inboundReferences: [],
+ spacesWithMatchingAliases: ['space-1', 'space-2'], // space-1 and space-2 both exist, they are mocked at the top
+ },
+ ],
+ });
+
+ changeSpaceSelection(wrapper, ['space-1']);
+ await clickButton(wrapper, 'continue');
+
+ const aliasTable = wrapper.find(AliasTable);
+ expect(aliasTable.prop('aliasesToDisable')).toEqual([
+ { targetType: 'foo', sourceId: '1', targetSpace: 'space-1', spaceExists: true },
+ // even though an alias exists for space-2, it will not be disabled, because we aren't sharing to that space
+ ]);
+ expect(aliasTable.find(EuiCallOut).text()).toMatchInlineSnapshot(
+ `"Legacy URL conflict1 legacy URL will be disabled."`
+ );
+ });
+ });
+
+ describe('footer', () => {
+ it('does not show a description of relatives (references) if there are none', async () => {
+ const namespaces = ['my-active-space']; // the saved object's current namespaces
+ const { wrapper } = await setup({ namespaces });
+
+ const relativesControl = wrapper.find(RelativesFooter);
+ expect(relativesControl.isEmptyRender()).toBe(true);
+ });
+
+ it('shows a description of filtered relatives (references)', async () => {
+ const namespaces = ['my-active-space']; // the saved object's current namespaces
+ const { wrapper } = await setup({
+ namespaces,
+ additionalShareableReferences: [
+ // the saved object target is already included in the mock results by default; it will not be counted
+ { type: 'foo', id: '1', spaces: [], inboundReferences: [] }, // this will not be counted because spaces is empty (it may not be a shareable type)
+ { type: 'foo', id: '2', spaces: namespaces, inboundReferences: [], isMissing: true }, // this will not be counted because isMissing === true
+ { type: 'foo', id: '3', spaces: namespaces, inboundReferences: [] }, // this will be counted
+ ],
+ });
+
+ const relativesControl = wrapper.find(RelativesFooter);
+ expect(relativesControl.isEmptyRender()).toBe(false);
+ expect(relativesControl.text()).toMatchInlineSnapshot(`"1 related object will also change."`);
+ });
+
+ function expectButton(wrapper: ReactWrapper, button: 'save' | 'continue') {
+ const saveButton = findTestSubject(wrapper, 'sts-save-button');
+ const continueButton = findTestSubject(wrapper, 'sts-continue-button');
+ expect(saveButton).toHaveLength(button === 'save' ? 1 : 0);
+ expect(continueButton).toHaveLength(button === 'continue' ? 1 : 0);
+ }
+
+ it('shows a save button if there are no legacy URL aliases to disable', async () => {
+ const namespaces = ['my-active-space']; // the saved object's current namespaces
+ const { wrapper } = await setup({ namespaces });
+
+ changeSpaceSelection(wrapper, ['*']);
+ expectButton(wrapper, 'save');
+ });
+
+ it('shows a save button if there are legacy URL aliases to disable, but none for existing spaces', async () => {
+ const namespaces = ['my-active-space']; // the saved object's current namespaces
+ const { wrapper } = await setup({
+ namespaces,
+ additionalShareableReferences: [
+ // it doesn't matter if aliases are for the saved object target or for references; this is easier to mock
+ {
+ type: 'foo',
+ id: '1',
+ spaces: namespaces,
+ inboundReferences: [],
+ spacesWithMatchingAliases: ['some-space-that-does-not-exist'],
+ },
+ ],
+ });
+
+ changeSpaceSelection(wrapper, ['*']);
+ expectButton(wrapper, 'save');
+ });
+
+ it('shows a continue button if there are legacy URL aliases to disable for existing spaces', async () => {
+ const namespaces = ['my-active-space']; // the saved object's current namespaces
+ const { wrapper } = await setup({
+ namespaces,
+ additionalShareableReferences: [
+ // it doesn't matter if aliases are for the saved object target or for references; this is easier to mock
+ {
+ type: 'foo',
+ id: '1',
+ spaces: namespaces,
+ inboundReferences: [],
+ spacesWithMatchingAliases: ['space-1', 'some-space-that-does-not-exist'], // space-1 exists, it is mocked at the top
+ },
+ ],
+ });
+
+ changeSpaceSelection(wrapper, ['*']);
+ expectButton(wrapper, 'continue');
+ });
+ });
});
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx
index d8fc0f299d8e6..712adeb26bccb 100644
--- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx
@@ -5,18 +5,19 @@
* 2.0.
*/
+import './share_to_space_flyout_internal.scss';
+
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
- EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
- EuiHorizontalRule,
EuiIcon,
EuiLoadingSpinner,
+ EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
@@ -24,7 +25,7 @@ import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
-import type { ToastsStart } from 'src/core/public';
+import type { SavedObjectReferenceWithContext, ToastsStart } from 'src/core/public';
import type {
ShareToSpaceFlyoutProps,
ShareToSpaceSavedObjectTarget,
@@ -36,8 +37,11 @@ import { useSpaces } from '../../spaces_context';
import type { SpacesManager } from '../../spaces_manager';
import type { ShareToSpaceTarget } from '../../types';
import type { ShareOptions } from '../types';
+import { AliasTable } from './alias_table';
import { DEFAULT_OBJECT_NOUN } from './constants';
+import { RelativesFooter } from './relatives_footer';
import { ShareToSpaceForm } from './share_to_space_form';
+import type { InternalLegacyUrlAliasTarget } from './types';
// 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.
@@ -66,45 +70,53 @@ function createDefaultChangeSpacesHandler(
spacesManager: SpacesManager,
toastNotifications: ToastsStart
) {
- return async (spacesToAdd: string[], spacesToRemove: string[]) => {
- const { type, id, title } = object;
- const objects = [{ type, id }];
+ return async (
+ objects: Array<{ type: string; id: string }>,
+ spacesToAdd: string[],
+ spacesToRemove: string[]
+ ) => {
+ const { title } = object;
+ const objectsToUpdate = objects.map(({ type, id }) => ({ type, id })); // only use 'type' and 'id' fields
+ const relativesCount = objects.length - 1;
const toastTitle = i18n.translate('xpack.spaces.shareToSpace.shareSuccessTitle', {
values: { objectNoun: object.noun },
defaultMessage: 'Updated {objectNoun}',
description: `Object noun can be plural or singular, examples: "Updated objects", "Updated job"`,
});
- await spacesManager.updateSavedObjectsSpaces(objects, spacesToAdd, spacesToRemove);
+ await spacesManager.updateSavedObjectsSpaces(objectsToUpdate, spacesToAdd, spacesToRemove);
const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID);
let toastText: string;
if (spacesToAdd.length > 0 && spacesToRemove.length > 0 && !isSharedToAllSpaces) {
toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessAddRemoveText', {
- defaultMessage: `'{object}' was added to {spacesTargetAdd} and removed from {spacesTargetRemove}.`, // TODO: update to include # of references and/or # of tags
+ defaultMessage: `'{object}' {relativesCount, plural, =0 {was} =1 {and {relativesCount} related object were} other {and {relativesCount} related objects were}} added to {spacesTargetAdd} and removed from {spacesTargetRemove}.`,
values: {
object: title,
+ relativesCount,
spacesTargetAdd: getSpacesTargetString(spacesToAdd),
spacesTargetRemove: getSpacesTargetString(spacesToRemove),
},
- description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget...' inputs. Example strings: "'Finance dashboard' was added to 1 space and removed from 2 spaces.", "'Finance dashboard' was added to 3 spaces and removed from all spaces."`,
+ description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget...' inputs. Example strings: "'Finance dashboard' was added to 1 space and removed from 2 spaces.", "'Finance dashboard' and 2 related objects were added to 3 spaces and removed from all spaces."`,
});
} else if (spacesToAdd.length > 0) {
toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessAddText', {
- defaultMessage: `'{object}' was added to {spacesTarget}.`, // TODO: update to include # of references and/or # of tags
+ defaultMessage: `'{object}' {relativesCount, plural, =0 {was} =1 {and {relativesCount} related object were} other {and {relativesCount} related objects were}} added to {spacesTarget}.`,
values: {
object: title,
+ relativesCount,
spacesTarget: getSpacesTargetString(spacesToAdd),
},
- description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was added to 1 space.", "'Finance dashboard' was added to all spaces."`,
+ description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was added to 1 space.", "'Finance dashboard' and 2 related objects were added to all spaces."`,
});
} else {
toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessRemoveText', {
- defaultMessage: `'{object}' was removed from {spacesTarget}.`, // TODO: update to include # of references and/or # of tags
+ defaultMessage: `'{object}' {relativesCount, plural, =0 {was} =1 {and {relativesCount} related object were} other {and {relativesCount} related objects were}} removed from {spacesTarget}.`,
values: {
object: title,
+ relativesCount,
spacesTarget: getSpacesTargetString(spacesToRemove),
},
- description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was removed from 1 space.", "'Finance dashboard' was removed from all spaces."`,
+ description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was removed from 1 space.", "'Finance dashboard' and 2 related objects were removed from all spaces."`,
});
}
toastNotifications.addSuccess({ title: toastTitle, text: toastText });
@@ -131,7 +143,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
const {
flyoutIcon,
flyoutTitle = i18n.translate('xpack.spaces.shareToSpace.flyoutTitle', {
- defaultMessage: 'Edit spaces for {objectNoun}',
+ defaultMessage: 'Assign {objectNoun} to spaces',
values: { objectNoun: savedObjectTarget.noun },
}),
enableCreateCopyCallout = false,
@@ -154,13 +166,15 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false);
const [showMakeCopy, setShowMakeCopy] = useState(false);
- const [{ isLoading, spaces }, setSpacesState] = useState<{
+ const [{ isLoading, spaces, referenceGraph, aliasTargets }, setSpacesState] = useState<{
isLoading: boolean;
spaces: ShareToSpaceTarget[];
- }>({ isLoading: true, spaces: [] });
+ referenceGraph: SavedObjectReferenceWithContext[];
+ aliasTargets: InternalLegacyUrlAliasTarget[];
+ }>({ isLoading: true, spaces: [], referenceGraph: [], aliasTargets: [] });
useEffect(() => {
const { type, id } = savedObjectTarget;
- const getShareableReferences = spacesManager.getShareableReferences([{ type, id }]); // NOTE: not used yet, this is just included so you can see the request/response in Dev Tools
+ const getShareableReferences = spacesManager.getShareableReferences([{ type, id }]);
const getPermissions = spacesManager.getShareSavedObjectPermissions(type);
Promise.all([shareToSpacesDataPromise, getShareableReferences, getPermissions])
.then(([shareToSpacesData, shareableReferences, permissions]) => {
@@ -176,6 +190,20 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
setSpacesState({
isLoading: false,
spaces: [...shareToSpacesData.spacesMap].map(([, spaceTarget]) => spaceTarget),
+ referenceGraph: shareableReferences.objects,
+ aliasTargets: shareableReferences.objects.reduce(
+ (acc, x) => {
+ for (const space of x.spacesWithMatchingAliases ?? []) {
+ if (space !== '?') {
+ const spaceExists = shareToSpacesData.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 });
+ }
+ }
+ return acc;
+ },
+ []
+ ),
});
})
.catch((e) => {
@@ -195,7 +223,12 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
const getSelectionChanges = () => {
if (!spaces.length) {
- return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] };
+ return {
+ isSelectionChanged: false,
+ spacesToAdd: [],
+ spacesToRemove: [],
+ aliasesToDisable: [],
+ };
}
const activeSpaceId =
!enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id;
@@ -231,21 +264,36 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
: isUnsharedFromAllSpaces
? [...activeSpaceArray, ...selectedSpacesToAdd]
: selectedSpacesToAdd;
+ const spacesToAddSet = new Set(spacesToAdd);
const spacesToRemove =
isUnsharedFromAllSpaces || !isSharedToAllSpaces
? selectedSpacesToRemove
: [...activeSpaceArray, ...initialSelection];
- return { isSelectionChanged, spacesToAdd, spacesToRemove };
+ const aliasesToDisable = isSharedToAllSpaces
+ ? aliasTargets
+ : aliasTargets.filter(({ targetSpace }) => spacesToAddSet.has(targetSpace));
+ return { isSelectionChanged, spacesToAdd, spacesToRemove, aliasesToDisable };
};
- const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges();
+ const {
+ isSelectionChanged,
+ spacesToAdd,
+ spacesToRemove,
+ aliasesToDisable,
+ } = getSelectionChanges();
+ const [showAliasesToDisable, setShowAliasesToDisable] = useState(false);
const [shareInProgress, setShareInProgress] = useState(false);
async function startShare() {
setShareInProgress(true);
try {
- await changeSpacesHandler(spacesToAdd, spacesToRemove);
- onUpdate();
+ if (aliasesToDisable.length) {
+ const aliases = aliasesToDisable.map(({ spaceExists, ...alias }) => alias); // only use 'targetSpace', 'targetType', and 'sourceId' fields
+ await spacesManager.disableLegacyUrlAliases(aliases);
+ }
+ await changeSpacesHandler(referenceGraph, spacesToAdd, spacesToRemove);
+ const updatedObjects = referenceGraph.map(({ type, id }) => ({ type, id })); // only use 'type' and 'id' fields
+ onUpdate(updatedObjects);
onClose();
} catch (e) {
setShareInProgress(false);
@@ -264,27 +312,86 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
return ;
}
- // If the object has not been shared yet (e.g., it currently exists in exactly one space), and there is at least one space that we could
- // share this object to, we want to display a callout to the user that explains the ramifications of shared objects. They might actually
- // want to make a copy instead, so this callout contains a link that opens the Copy flyout.
- const showCreateCopyCallout =
- enableCreateCopyCallout &&
- spaces.length > 1 &&
- savedObjectTarget.namespaces.length === 1 &&
- !arraysAreEqual(savedObjectTarget.namespaces, [ALL_SPACES_ID]);
- // Step 2: Share has not been initiated yet; User must fill out form to continue.
+ if (!showAliasesToDisable) {
+ // If the object has not been shared yet (e.g., it currently exists in exactly one space), and there is at least one space that we could
+ // share this object to, we want to display a callout to the user that explains the ramifications of shared objects. They might actually
+ // want to make a copy instead, so this callout contains a link that opens the Copy flyout.
+ const showCreateCopyCallout =
+ enableCreateCopyCallout &&
+ spaces.length > 1 &&
+ savedObjectTarget.namespaces.length === 1 &&
+ !arraysAreEqual(savedObjectTarget.namespaces, [ALL_SPACES_ID]);
+ // Step 2: Share has not been initiated yet; User must fill out form to continue.
+ return (
+ setShowMakeCopy(true)}
+ enableCreateNewSpaceLink={enableCreateNewSpaceLink}
+ enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
+ />
+ );
+ }
+
+ return ;
+ };
+
+ const getFlyoutFooter = () => {
+ const filteredAliasesToDisable = aliasesToDisable.filter(({ spaceExists }) => spaceExists);
+ const showContinueButton = filteredAliasesToDisable.length && !showAliasesToDisable;
return (
- setShowMakeCopy(true)}
- enableCreateNewSpaceLink={enableCreateNewSpaceLink}
- enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
- />
+ <>
+
+
+
+ onClose()}
+ data-test-subj="sts-cancel-button"
+ disabled={shareInProgress}
+ >
+
+
+
+
+ {showContinueButton ? (
+ setShowAliasesToDisable(true)}
+ data-test-subj="sts-continue-button"
+ disabled={isStartShareButtonDisabled}
+ >
+
+
+ ) : (
+ startShare()}
+ data-test-subj="sts-save-button"
+ disabled={isStartShareButtonDisabled}
+ >
+
+
+ )}
+
+
+ >
);
};
@@ -317,54 +424,33 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => {
-
-
- {savedObjectTarget.icon && (
-
-
+
+
+
+
+ {savedObjectTarget.icon && (
+
+
+
+ )}
+
+
+ {savedObjectTarget.title}
+
- )}
-
-
- {savedObjectTarget.title}
-
-
-
+
+
-
+
{getFlyoutBody()}
-
+
-
-
-
- onClose()}
- data-test-subj="sts-cancel-button"
- disabled={shareInProgress}
- >
-
-
-
-
- startShare()}
- data-test-subj="sts-initiate-button"
- disabled={isStartShareButtonDisabled}
- >
-
-
-
-
-
+ {getFlyoutFooter()}
);
};
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx
index 65ed0139d0dd8..7f8c659805c45 100644
--- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx
@@ -61,7 +61,7 @@ export const ShareToSpaceForm = (props: Props) => {
defaultMessage="Your changes appear in each space you select. {makeACopyLink} if you don't want to synchronize your changes."
values={{
makeACopyLink: (
- makeCopy()}>
+ makeCopy()}>
{
) : null;
return (
-
+ <>
{createCopyCallout}
{
enableCreateNewSpaceLink={enableCreateNewSpaceLink}
enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior}
/>
-
+ >
);
};
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/types.ts
new file mode 100644
index 0000000000000..ba39dd2499f4c
--- /dev/null
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/types.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import type { LegacyUrlAliasTarget } from '../../../common';
+
+export interface InternalLegacyUrlAliasTarget extends LegacyUrlAliasTarget {
+ /**
+ * We could potentially have an alias for a space that does not exist; in that case, we may need disable it, but we don't want to show it
+ * in the UI.
+ */
+ spaceExists: boolean;
+}
diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx
index 9a0d171342a80..90dda8ad0b013 100644
--- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx
+++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx
@@ -44,13 +44,13 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage
return namespaceType === 'multiple' && !hiddenType && hasCapability;
},
onClick: (object: SavedObjectsManagementRecord) => {
- this.isDataChanged = false;
+ this.objectsToRefresh = [];
this.start(object);
},
};
- public refreshOnFinish = () => this.isDataChanged;
+ public refreshOnFinish = () => this.objectsToRefresh;
- private isDataChanged: boolean = false;
+ private objectsToRefresh: Array<{ type: string; id: string }> = [];
constructor(private readonly spacesApiUi: SpacesApiUi) {
super();
@@ -70,7 +70,8 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage
icon: this.record.meta.icon,
},
flyoutIcon: 'share',
- onUpdate: () => (this.isDataChanged = true),
+ onUpdate: (updatedObjects: Array<{ type: string; id: string }>) =>
+ (this.objectsToRefresh = [...updatedObjects]),
onClose: this.onClose,
enableCreateCopyCallout: true,
enableCreateNewSpaceLink: true,
diff --git a/x-pack/plugins/spaces/public/space_avatar/space_avatar_internal.tsx b/x-pack/plugins/spaces/public/space_avatar/space_avatar_internal.tsx
index 9a3a112110bbf..91b4dbf8a964e 100644
--- a/x-pack/plugins/spaces/public/space_avatar/space_avatar_internal.tsx
+++ b/x-pack/plugins/spaces/public/space_avatar/space_avatar_internal.tsx
@@ -20,6 +20,12 @@ interface Props {
size?: 's' | 'm' | 'l' | 'xl';
className?: string;
announceSpaceName?: boolean;
+ /**
+ * This property is passed to the underlying `EuiAvatar` component. If enabled, the SpaceAvatar will have a grayed out appearance. For
+ * example, this can be useful when rendering a list of spaces for a specific feature, if the feature is disabled in one of those spaces.
+ * Default: false.
+ */
+ isDisabled?: boolean;
}
export const SpaceAvatarInternal: FC = (props: Props) => {
diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx
index 1a512fb2d31f4..ac7e6446f2ccd 100644
--- a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx
+++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx
@@ -142,11 +142,10 @@ export const SpaceListInternal = ({
}>
{displayedSpaces.map((space) => {
- // color may be undefined, which is intentional; SpacesAvatar calls the getSpaceColor function before rendering
- const color = space.isFeatureDisabled ? 'hollow' : space.color;
+ const isDisabled = space.isFeatureDisabled;
return (
-
+
);
})}
diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts
index 39c06a2bc874d..5282163f93b15 100644
--- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts
+++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts
@@ -21,6 +21,7 @@ function createSpacesManagerMock() {
createSpace: jest.fn().mockResolvedValue(undefined),
updateSpace: jest.fn().mockResolvedValue(undefined),
deleteSpace: jest.fn().mockResolvedValue(undefined),
+ disableLegacyUrlAliases: jest.fn().mockResolvedValue(undefined),
copySavedObjects: jest.fn().mockResolvedValue(undefined),
getShareableReferences: jest.fn().mockResolvedValue(undefined),
updateSavedObjectsSpaces: jest.fn().mockResolvedValue(undefined),
diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts
index ff18b2965e8b9..d09177a915d99 100644
--- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts
+++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts
@@ -154,4 +154,33 @@ describe('SpacesManager', () => {
);
});
});
+
+ describe('#getShareableReferences', () => {
+ it('retrieves the shareable references, filters out references that are tags, and returns the result', async () => {
+ const obj1 = { type: 'not-a-tag', id: '1' }; // requested object
+ const obj2 = { type: 'tag', id: '2' }; // requested object
+ const obj3 = { type: 'tag', id: '3' }; // referenced object
+ const obj4 = { type: 'not-a-tag', id: '4' }; // referenced object
+
+ const coreStart = coreMock.createStart();
+ coreStart.http.post.mockResolvedValue({ objects: [obj1, obj2, obj3, obj4] }); // A realistic response would include additional fields besides 'type' and 'id', but they are not needed for this test case
+ const spacesManager = new SpacesManager(coreStart.http);
+
+ const requestObjects = [obj1, obj2];
+ const result = await spacesManager.getShareableReferences(requestObjects);
+ expect(coreStart.http.post).toHaveBeenCalledTimes(1);
+ expect(coreStart.http.post).toHaveBeenLastCalledWith(
+ '/api/spaces/_get_shareable_references',
+ { body: JSON.stringify({ objects: requestObjects }) }
+ );
+ expect(result).toEqual({
+ objects: [
+ obj1, // obj1 is not a tag
+ obj2, // obj2 is a tag, but it was included in the request, so it is not excluded from the response
+ // obj3 is a tag, but it was not included in the request, so it is excluded from the response
+ obj4, // obj4 is not a tag
+ ],
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts
index a7201def5ed40..845373bf22299 100644
--- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts
+++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts
@@ -15,7 +15,7 @@ import type {
} from 'src/core/public';
import type { Space } from 'src/plugins/spaces_oss/common';
-import type { GetAllSpacesOptions, GetSpaceResult } from '../../common';
+import type { GetAllSpacesOptions, GetSpaceResult, LegacyUrlAliasTarget } from '../../common';
import type { CopySavedObjectsToSpaceResponse } from '../copy_saved_objects_to_space/types';
interface SavedObjectTarget {
@@ -23,6 +23,8 @@ interface SavedObjectTarget {
id: string;
}
+const TAG_TYPE = 'tag';
+
export class SpacesManager {
private activeSpace$: BehaviorSubject = new BehaviorSubject(null);
@@ -90,6 +92,12 @@ export class SpacesManager {
await this.http.delete(`/api/spaces/space/${encodeURIComponent(space.id)}`);
}
+ public async disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]) {
+ await this.http.post('/api/spaces/_disable_legacy_url_aliases', {
+ body: JSON.stringify({ aliases }),
+ });
+ }
+
public async copySavedObjects(
objects: SavedObjectTarget[],
spaces: string[],
@@ -142,9 +150,21 @@ export class SpacesManager {
public async getShareableReferences(
objects: SavedObjectTarget[]
): Promise {
- return this.http.post(`/api/spaces/_get_shareable_references`, {
- body: JSON.stringify({ objects }),
- });
+ const response = await this.http.post(
+ `/api/spaces/_get_shareable_references`,
+ { body: JSON.stringify({ objects }) }
+ );
+
+ // We should exclude any child-reference tags because we don't yet support reconciling/merging duplicate tags. In other words: tags can
+ // be shared directly, but if a tag is only included as a reference of a requested object, it should not be shared.
+ const requestedObjectsSet = objects.reduce(
+ (acc, { type, id }) => acc.add(`${type}:${id}`),
+ new Set()
+ );
+ const filteredObjects = response.objects.filter(
+ ({ type, id }) => type !== TAG_TYPE || requestedObjectsSet.has(`${type}:${id}`)
+ );
+ return { objects: filteredObjects };
}
public async updateSavedObjectsSpaces(
diff --git a/x-pack/plugins/spaces/public/suspense_error_boundary/suspense_error_boundary.tsx b/x-pack/plugins/spaces/public/suspense_error_boundary/suspense_error_boundary.tsx
index e90920f3f1e25..e2a1ce69deee8 100644
--- a/x-pack/plugins/spaces/public/suspense_error_boundary/suspense_error_boundary.tsx
+++ b/x-pack/plugins/spaces/public/suspense_error_boundary/suspense_error_boundary.tsx
@@ -14,6 +14,12 @@ import type { NotificationsStart } from 'src/core/public';
interface Props {
notifications: NotificationsStart;
+ /**
+ * Whether or not to show a loading spinner while waiting for the child components to load.
+ *
+ * Default is true.
+ */
+ showLoadingSpinner?: boolean;
}
interface State {
@@ -44,11 +50,12 @@ export class SuspenseErrorBoundary extends Component, S
}
render() {
- const { children, notifications } = this.props;
+ const { children, notifications, showLoadingSpinner = true } = this.props;
const { error } = this.state;
if (!notifications || error) {
return null;
}
- return }>{children};
+ const fallback = showLoadingSpinner ? : null;
+ return {children};
}
}
diff --git a/x-pack/plugins/spaces/public/ui_api/components.tsx b/x-pack/plugins/spaces/public/ui_api/components.tsx
index b564e96be4c41..a277e3a1dd119 100644
--- a/x-pack/plugins/spaces/public/ui_api/components.tsx
+++ b/x-pack/plugins/spaces/public/ui_api/components.tsx
@@ -34,9 +34,15 @@ export const getComponents = ({
/**
* Returns a function that creates a lazy-loading version of a component.
*/
- function wrapLazy(fn: () => Promise>) {
+ function wrapLazy(fn: () => Promise>, options: { showLoadingSpinner?: boolean } = {}) {
+ const { showLoadingSpinner } = options;
return (props: JSX.IntrinsicAttributes & PropsWithRef>) => (
-
+
);
}
@@ -44,7 +50,7 @@ export const getComponents = ({
getSpacesContextProvider: wrapLazy(() =>
getSpacesContextProviderWrapper({ spacesManager, getStartServices })
),
- getShareToSpaceFlyout: wrapLazy(getShareToSpaceFlyoutComponent),
+ getShareToSpaceFlyout: wrapLazy(getShareToSpaceFlyoutComponent, { showLoadingSpinner: false }),
getSpaceList: wrapLazy(getSpaceListComponent),
getLegacyUrlConflict: wrapLazy(() => getLegacyUrlConflict({ getStartServices })),
getSpaceAvatar: wrapLazy(getSpaceAvatarComponent),
diff --git a/x-pack/plugins/spaces/public/ui_api/lazy_wrapper.tsx b/x-pack/plugins/spaces/public/ui_api/lazy_wrapper.tsx
index 9c3336cfca01d..384eee6c439c9 100644
--- a/x-pack/plugins/spaces/public/ui_api/lazy_wrapper.tsx
+++ b/x-pack/plugins/spaces/public/ui_api/lazy_wrapper.tsx
@@ -17,12 +17,14 @@ import { SuspenseErrorBoundary } from '../suspense_error_boundary';
interface InternalProps {
fn: () => Promise>;
getStartServices: StartServicesAccessor;
+ showLoadingSpinner?: boolean;
props: JSX.IntrinsicAttributes & PropsWithRef>;
}
export const LazyWrapper: (props: InternalProps) => ReactElement | null = ({
fn,
getStartServices,
+ showLoadingSpinner,
props,
}) => {
const { value: startServices = [{ notifications: undefined }] } = useAsync(getStartServices);
@@ -35,7 +37,7 @@ export const LazyWrapper: (props: InternalProps) => ReactElement | null =
}
return (
-
+
);
diff --git a/x-pack/plugins/spaces/server/index.ts b/x-pack/plugins/spaces/server/index.ts
index c779e92a3ae9b..4765b06f5a02a 100644
--- a/x-pack/plugins/spaces/server/index.ts
+++ b/x-pack/plugins/spaces/server/index.ts
@@ -23,7 +23,12 @@ export { SpacesPluginSetup, SpacesPluginStart } from './plugin';
export { SpacesServiceSetup, SpacesServiceStart } from './spaces_service';
export { ISpacesClient, SpacesClientRepositoryFactory, SpacesClientWrapper } from './spaces_client';
-export { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from '../common';
+export {
+ GetAllSpacesOptions,
+ GetAllSpacesPurpose,
+ GetSpaceResult,
+ LegacyUrlAliasTarget,
+} from '../common';
// re-export types from oss definition
export type { Space } from 'src/plugins/spaces_oss/common';
diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts
index 13398925490fa..acc13be6802c2 100644
--- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts
+++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts
@@ -35,6 +35,7 @@ export const createMockSavedObjectsRepository = (spaces: any[] = []) => {
}
return {};
}),
+ bulkUpdate: jest.fn(),
delete: jest.fn((type: string, id: string) => {
return {};
}),
diff --git a/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.test.ts b/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.test.ts
new file mode 100644
index 0000000000000..e475b6b21569a
--- /dev/null
+++ b/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.test.ts
@@ -0,0 +1,143 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import * as Rx from 'rxjs';
+
+import type { RouteValidatorConfig } from 'src/core/server';
+import { kibanaResponseFactory } from 'src/core/server';
+import {
+ coreMock,
+ httpServerMock,
+ httpServiceMock,
+ loggingSystemMock,
+} from 'src/core/server/mocks';
+
+import { spacesConfig } from '../../../lib/__fixtures__';
+import { SpacesClientService } from '../../../spaces_client';
+import { SpacesService } from '../../../spaces_service';
+import { usageStatsClientMock } from '../../../usage_stats/usage_stats_client.mock';
+import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock';
+import {
+ createMockSavedObjectsRepository,
+ createSpaces,
+ mockRouteContext,
+ mockRouteContextWithInvalidLicense,
+} from '../__fixtures__';
+import { initDisableLegacyUrlAliasesApi } from './disable_legacy_url_aliases';
+
+describe('_disable_legacy_url_aliases', () => {
+ const spacesSavedObjects = createSpaces();
+
+ const setup = async () => {
+ const httpService = httpServiceMock.createSetupContract();
+ const router = httpService.createRouter();
+
+ const coreStart = coreMock.createStart();
+
+ const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects);
+
+ const log = loggingSystemMock.create().get('spaces');
+
+ const clientService = new SpacesClientService(jest.fn());
+ clientService
+ .setup({ config$: Rx.of(spacesConfig) })
+ .setClientRepositoryFactory(() => savedObjectsRepositoryMock);
+
+ const service = new SpacesService();
+ service.setup({
+ basePath: httpService.basePath,
+ });
+
+ const usageStatsClient = usageStatsClientMock.create();
+ const usageStatsServicePromise = Promise.resolve(
+ usageStatsServiceMock.createSetupContract(usageStatsClient)
+ );
+
+ const clientServiceStart = clientService.start(coreStart);
+
+ const spacesServiceStart = service.start({
+ basePath: coreStart.http.basePath,
+ spacesClientService: clientServiceStart,
+ });
+
+ initDisableLegacyUrlAliasesApi({
+ externalRouter: router,
+ getStartServices: async () => [coreStart, {}, {}],
+ log,
+ getSpacesService: () => spacesServiceStart,
+ usageStatsServicePromise,
+ });
+
+ const [routeDefinition, routeHandler] = router.post.mock.calls[0];
+
+ return {
+ routeValidation: routeDefinition.validate as RouteValidatorConfig<{}, {}, {}>,
+ routeHandler,
+ savedObjectsRepositoryMock,
+ usageStatsClient,
+ };
+ };
+
+ it('records usageStats data', async () => {
+ const payload = {
+ aliases: [{ targetSpace: 'space-1', targetType: 'type-1', sourceId: 'id-1' }],
+ };
+
+ const { routeHandler, usageStatsClient } = await setup();
+
+ const request = httpServerMock.createKibanaRequest({
+ body: payload,
+ method: 'post',
+ });
+
+ await routeHandler(mockRouteContext, request, kibanaResponseFactory);
+
+ expect(usageStatsClient.incrementDisableLegacyUrlAliases).toHaveBeenCalled();
+ });
+
+ it('should disable the provided aliases', async () => {
+ const payload = {
+ aliases: [{ targetSpace: 'space-1', targetType: 'type-1', sourceId: 'id-1' }],
+ };
+
+ const { routeHandler, savedObjectsRepositoryMock } = await setup();
+
+ const request = httpServerMock.createKibanaRequest({
+ body: payload,
+ method: 'post',
+ });
+
+ const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory);
+
+ const { status } = response;
+
+ expect(status).toEqual(204);
+ expect(savedObjectsRepositoryMock.bulkUpdate).toHaveBeenCalledTimes(1);
+ expect(savedObjectsRepositoryMock.bulkUpdate).toHaveBeenCalledWith([
+ { type: 'legacy-url-alias', id: 'space-1:type-1:id-1', attributes: { disabled: true } },
+ ]);
+ });
+
+ it(`returns http/403 when the license is invalid`, async () => {
+ const { routeHandler } = await setup();
+
+ const request = httpServerMock.createKibanaRequest({
+ method: 'post',
+ });
+
+ const response = await routeHandler(
+ mockRouteContextWithInvalidLicense,
+ request,
+ kibanaResponseFactory
+ );
+
+ expect(response.status).toEqual(403);
+ expect(response.payload).toEqual({
+ message: 'License is invalid for spaces',
+ });
+ });
+});
diff --git a/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts b/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts
new file mode 100644
index 0000000000000..a14744ddc5eeb
--- /dev/null
+++ b/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts
@@ -0,0 +1,50 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { schema } from '@kbn/config-schema';
+
+import { wrapError } from '../../../lib/errors';
+import { createLicensedRouteHandler } from '../../lib';
+import type { ExternalRouteDeps } from './';
+
+export function initDisableLegacyUrlAliasesApi(deps: ExternalRouteDeps) {
+ const { externalRouter, getSpacesService, usageStatsServicePromise } = deps;
+ const usageStatsClientPromise = usageStatsServicePromise.then(({ getClient }) => getClient());
+
+ externalRouter.post(
+ {
+ path: '/api/spaces/_disable_legacy_url_aliases',
+ validate: {
+ body: schema.object({
+ aliases: schema.arrayOf(
+ schema.object({
+ targetSpace: schema.string(),
+ targetType: schema.string(),
+ sourceId: schema.string(),
+ })
+ ),
+ }),
+ },
+ },
+ createLicensedRouteHandler(async (_context, request, response) => {
+ const spacesClient = getSpacesService().createSpacesClient(request);
+
+ const { aliases } = request.body;
+
+ usageStatsClientPromise.then((usageStatsClient) =>
+ usageStatsClient.incrementDisableLegacyUrlAliases()
+ );
+
+ try {
+ await spacesClient.disableLegacyUrlAliases(aliases);
+ return response.noContent();
+ } catch (error) {
+ return response.customError(wrapError(error));
+ }
+ })
+ );
+}
diff --git a/x-pack/plugins/spaces/server/routes/api/external/index.ts b/x-pack/plugins/spaces/server/routes/api/external/index.ts
index 9cebd8d0f9352..c42e57dea736d 100644
--- a/x-pack/plugins/spaces/server/routes/api/external/index.ts
+++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts
@@ -12,6 +12,7 @@ import type { SpacesRouter } from '../../../types';
import type { UsageStatsServiceSetup } from '../../../usage_stats';
import { initCopyToSpacesApi } from './copy_to_space';
import { initDeleteSpacesApi } from './delete';
+import { initDisableLegacyUrlAliasesApi } from './disable_legacy_url_aliases';
import { initGetSpaceApi } from './get';
import { initGetAllSpacesApi } from './get_all';
import { initGetShareableReferencesApi } from './get_shareable_references';
@@ -36,4 +37,5 @@ export function initExternalSpacesApi(deps: ExternalRouteDeps) {
initCopyToSpacesApi(deps);
initUpdateObjectsSpacesApi(deps);
initGetShareableReferencesApi(deps);
+ initDisableLegacyUrlAliasesApi(deps);
}
diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts
index f03e26ea8e714..d893d2b089f89 100644
--- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts
+++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts
@@ -30,6 +30,7 @@ const createSpacesClientMock = () =>
create: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)),
update: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)),
delete: jest.fn(),
+ disableLegacyUrlAliases: jest.fn(),
} as unknown) as jest.Mocked);
export const spacesClientMock = {
diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts
index 1d5d3851ce9c5..ae9fc254c0934 100644
--- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts
+++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts
@@ -341,4 +341,25 @@ describe('#delete', () => {
expect(mockCallWithRequestRepository.delete).toHaveBeenCalledWith('space', id);
expect(mockCallWithRequestRepository.deleteByNamespace).toHaveBeenCalledWith(id);
});
+
+ describe('#disableLegacyUrlAliases', () => {
+ test(`updates legacy URL aliases using callWithRequestRepository`, async () => {
+ const mockDebugLogger = createMockDebugLogger();
+ const mockConfig = createMockConfig();
+ const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
+
+ const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository);
+ const aliases = [
+ { targetSpace: 'space1', targetType: 'foo', sourceId: '123' },
+ { targetSpace: 'space2', targetType: 'bar', sourceId: '456' },
+ ];
+ await client.disableLegacyUrlAliases(aliases);
+
+ expect(mockCallWithRequestRepository.bulkUpdate).toHaveBeenCalledTimes(1);
+ expect(mockCallWithRequestRepository.bulkUpdate).toHaveBeenCalledWith([
+ { type: 'legacy-url-alias', id: 'space1:foo:123', attributes: { disabled: true } },
+ { type: 'legacy-url-alias', id: 'space2:bar:456', attributes: { disabled: true } },
+ ]);
+ });
+ });
});
diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts
index 02aa4d0b976c0..824d6e28b9923 100644
--- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts
+++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts
@@ -11,7 +11,12 @@ import { omit } from 'lodash';
import type { ISavedObjectsRepository, SavedObject } from 'src/core/server';
import type { Space } from 'src/plugins/spaces_oss/common';
-import type { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from '../../common';
+import type {
+ GetAllSpacesOptions,
+ GetAllSpacesPurpose,
+ GetSpaceResult,
+ LegacyUrlAliasTarget,
+} from '../../common';
import { isReservedSpace } from '../../common';
import type { ConfigType } from '../config';
@@ -22,6 +27,7 @@ const SUPPORTED_GET_SPACE_PURPOSES: GetAllSpacesPurpose[] = [
'shareSavedObjectsIntoSpace',
];
const DEFAULT_PURPOSE = 'any';
+const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias';
/**
* Client interface for interacting with spaces.
@@ -57,6 +63,12 @@ export interface ISpacesClient {
* @param id the id of the space to delete.
*/
delete(id: string): Promise;
+
+ /**
+ * Disables the specified legacy URL aliases.
+ * @param aliases the aliases to disable.
+ */
+ disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]): Promise;
}
/**
@@ -135,6 +147,15 @@ export class SpacesClient implements ISpacesClient {
await this.repository.delete('space', id);
}
+ public async disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]) {
+ const attributes = { disabled: true };
+ const objectsToUpdate = aliases.map(({ targetSpace, targetType, sourceId }) => {
+ const id = `${targetSpace}:${targetType}:${sourceId}`;
+ return { type: LEGACY_URL_ALIAS_TYPE, id, attributes };
+ });
+ await this.repository.bulkUpdate(objectsToUpdate);
+ }
+
private transformSavedObjectToSpace(savedObject: SavedObject) {
return {
id: savedObject.id,
diff --git a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts
index 19228614dc614..655463ed64a30 100644
--- a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts
+++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts
@@ -40,6 +40,7 @@ const MOCK_USAGE_STATS: UsageStats = {
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no': 0,
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes': 6,
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no': 7,
+ 'apiCalls.disableLegacyUrlAliases.total': 17,
};
function setup({
diff --git a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts
index 93892378717d5..b5c0972031a8f 100644
--- a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts
+++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts
@@ -425,6 +425,12 @@ export function getSpacesUsageCollector(
'The number of times the "Resolve Copy Saved Objects Errors" API has been called with "createNewCopies" set to false.',
},
},
+ 'apiCalls.disableLegacyUrlAliases.total': {
+ type: 'long',
+ _meta: {
+ description: 'The number of times the "Disable Legacy URL Aliases" API has been called.',
+ },
+ },
},
fetch: async ({ esClient }: CollectorFetchContext) => {
const { licensing, kibanaIndexConfig$, features, usageStatsServicePromise } = deps;
diff --git a/x-pack/plugins/spaces/server/usage_stats/types.ts b/x-pack/plugins/spaces/server/usage_stats/types.ts
index c0ea2c98ac4b8..81fe720cba745 100644
--- a/x-pack/plugins/spaces/server/usage_stats/types.ts
+++ b/x-pack/plugins/spaces/server/usage_stats/types.ts
@@ -18,4 +18,5 @@ export interface UsageStats {
'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no'?: number;
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes'?: number;
'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no'?: number;
+ 'apiCalls.disableLegacyUrlAliases.total'?: number;
}
diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts
index 32380b1a21048..c9f73451c7553 100644
--- a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts
+++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts
@@ -12,6 +12,7 @@ const createUsageStatsClientMock = () =>
getUsageStats: jest.fn().mockResolvedValue({}),
incrementCopySavedObjects: jest.fn().mockResolvedValue(null),
incrementResolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(null),
+ incrementDisableLegacyUrlAliases: jest.fn().mockResolvedValue(null),
} as unknown) as jest.Mocked);
export const usageStatsClientMock = {
diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts
index 6a56cb68d2a16..c5b63a4d007b9 100644
--- a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts
+++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts
@@ -14,6 +14,7 @@ import type {
} from './usage_stats_client';
import {
COPY_STATS_PREFIX,
+ DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX,
RESOLVE_COPY_STATS_PREFIX,
UsageStatsClient,
} from './usage_stats_client';
@@ -50,6 +51,7 @@ describe('UsageStatsClient', () => {
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
+ `${DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX}.total`,
],
{ initialize: true }
);
@@ -131,7 +133,7 @@ describe('UsageStatsClient', () => {
});
describe('#incrementResolveCopySavedObjectsErrors', () => {
- it('does not throw an error if repository create operation fails', async () => {
+ it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
@@ -182,4 +184,27 @@ describe('UsageStatsClient', () => {
);
});
});
+
+ describe('#incrementDisableLegacyUrlAliases', () => {
+ it('does not throw an error if repository incrementCounter operation fails', async () => {
+ const { usageStatsClient, repositoryMock } = setup();
+ repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
+
+ await expect(usageStatsClient.incrementDisableLegacyUrlAliases()).resolves.toBeUndefined();
+ expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
+ });
+
+ it('uses the appropriate counter fields', async () => {
+ const { usageStatsClient, repositoryMock } = setup();
+
+ await usageStatsClient.incrementDisableLegacyUrlAliases();
+ expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
+ expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
+ SPACES_USAGE_STATS_TYPE,
+ SPACES_USAGE_STATS_ID,
+ [`${DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX}.total`],
+ incrementOptions
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts
index 5093dd42b5bfa..5dcf106d6cfb4 100644
--- a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts
+++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts
@@ -21,6 +21,7 @@ export type IncrementResolveCopySavedObjectsErrorsOptions = BaseIncrementOptions
export const COPY_STATS_PREFIX = 'apiCalls.copySavedObjects';
export const RESOLVE_COPY_STATS_PREFIX = 'apiCalls.resolveCopySavedObjectsErrors';
+export const DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX = 'apiCalls.disableLegacyUrlAliases';
const ALL_COUNTER_FIELDS = [
`${COPY_STATS_PREFIX}.total`,
`${COPY_STATS_PREFIX}.kibanaRequest.yes`,
@@ -34,6 +35,7 @@ const ALL_COUNTER_FIELDS = [
`${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`,
`${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`,
+ `${DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX}.total`,
];
export class UsageStatsClient {
constructor(
@@ -87,6 +89,11 @@ export class UsageStatsClient {
await this.updateUsageStats(counterFieldNames, RESOLVE_COPY_STATS_PREFIX);
}
+ public async incrementDisableLegacyUrlAliases() {
+ const counterFieldNames = ['total'];
+ await this.updateUsageStats(counterFieldNames, DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX);
+ }
+
private async updateUsageStats(counterFieldNames: string[], prefix: string) {
const options = { refresh: false };
try {
diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json
index 39852ebaeb46b..bab4244139df0 100644
--- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json
+++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json
@@ -5763,6 +5763,12 @@
"_meta": {
"description": "The number of times the \"Resolve Copy Saved Objects Errors\" API has been called with \"createNewCopies\" set to false."
}
+ },
+ "apiCalls.disableLegacyUrlAliases.total": {
+ "type": "long",
+ "_meta": {
+ "description": "The number of times the \"Disable Legacy URL Aliases\" API has been called."
+ }
}
}
},
diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json
index 1b44200566405..cd2fafb0abbc0 100644
--- a/x-pack/plugins/translations/translations/ja-JP.json
+++ b/x-pack/plugins/translations/translations/ja-JP.json
@@ -3412,7 +3412,6 @@
"savedObjectsManagement.objects.savedObjectsDescription": "保存された検索、ビジュアライゼーション、ダッシュボードのインポート、エクスポート、管理を行います。",
"savedObjectsManagement.objects.savedObjectsTitle": "保存されたオブジェクト",
"savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "キャンセル",
- "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "削除",
"savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName": "Id",
"savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName": "タイトル",
"savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.typeColumnName": "型",
@@ -22106,7 +22105,6 @@
"xpack.spaces.shareToSpace.columnTitle": "共有されているスペース",
"xpack.spaces.shareToSpace.currentSpaceBadge": "現在",
"xpack.spaces.shareToSpace.featureIsDisabledTooltip": "この機能はこのスペースでは無効です。",
- "xpack.spaces.shareToSpace.flyoutTitle": "{objectNoun}のスペースを編集",
"xpack.spaces.shareToSpace.legacyUrlConflictBody": "現在、{objectNoun} [id={currentObjectId}]を表示しています。このページのレガシーURLは別の{objectNoun} [id={otherObjectId}]を示しています。",
"xpack.spaces.shareToSpace.legacyUrlConflictDismissButton": "閉じる",
"xpack.spaces.shareToSpace.legacyUrlConflictLinkButton": "他の{objectNoun}に移動",
@@ -22125,14 +22123,7 @@
"xpack.spaces.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount}個が非表示",
"xpack.spaces.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount}個が選択済み",
"xpack.spaces.shareToSpace.shareModeControl.selectSpacesLabel": "スペースを選択",
- "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。",
- "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。",
- "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text": "現在と将来のすべてのスペースで{objectNoun}を使用可能にします。",
- "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title": "すべてのスペース",
- "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "選択したスペースでのみ{objectNoun}を使用可能にします。",
- "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "スペースを選択",
"xpack.spaces.shareToSpace.shareSuccessTitle": "{objectNoun}を更新しました",
- "xpack.spaces.shareToSpace.shareToSpacesButton": "保存して閉じる",
"xpack.spaces.shareToSpace.shareWarningBody": "変更は選択した各スペースに表示されます。変更を同期しない場合は、{makeACopyLink}。",
"xpack.spaces.shareToSpace.shareWarningLink": "コピーを作成",
"xpack.spaces.shareToSpace.shareWarningTitle": "変更はスペース全体で同期されます",
diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json
index 9636aa9ba5282..2522abd40f07f 100644
--- a/x-pack/plugins/translations/translations/zh-CN.json
+++ b/x-pack/plugins/translations/translations/zh-CN.json
@@ -3436,7 +3436,6 @@
"savedObjectsManagement.objects.savedObjectsDescription": "导入、导出和管理您的已保存搜索、可视化和仪表板。",
"savedObjectsManagement.objects.savedObjectsTitle": "已保存对象",
"savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "取消",
- "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "删除",
"savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName": "ID",
"savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName": "标题",
"savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.typeColumnName": "类型",
@@ -22460,7 +22459,6 @@
"xpack.spaces.shareToSpace.columnTitle": "共享工作区",
"xpack.spaces.shareToSpace.currentSpaceBadge": "当前",
"xpack.spaces.shareToSpace.featureIsDisabledTooltip": "此功能在此工作区中已禁用。",
- "xpack.spaces.shareToSpace.flyoutTitle": "编辑 {objectNoun} 的工作区",
"xpack.spaces.shareToSpace.legacyUrlConflictBody": "当前您正在查看 {objectNoun} [id={currentObjectId}]。此页面的旧 URL 显示不同的 {objectNoun} [id={otherObjectId}]。",
"xpack.spaces.shareToSpace.legacyUrlConflictDismissButton": "关闭",
"xpack.spaces.shareToSpace.legacyUrlConflictLinkButton": "前往其他 {objectNoun}",
@@ -22479,14 +22477,9 @@
"xpack.spaces.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount} 个已隐藏",
"xpack.spaces.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount} 个已选择",
"xpack.spaces.shareToSpace.shareModeControl.selectSpacesLabel": "选择工作区",
- "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。",
- "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。",
"xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text": "使 {objectNoun} 在当前和将来的所有工作区中都可用。",
- "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title": "所有工作区",
"xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "仅使 {objectNoun} 在选定工作区中可用。",
- "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "选择工作区",
"xpack.spaces.shareToSpace.shareSuccessTitle": "已更新 {objectNoun}",
- "xpack.spaces.shareToSpace.shareToSpacesButton": "保存并关闭",
"xpack.spaces.shareToSpace.shareWarningBody": "您的更改显示在您选择的每个工作区中。如果不想同步您的更改,{makeACopyLink}。",
"xpack.spaces.shareToSpace.shareWarningLink": "创建副本",
"xpack.spaces.shareToSpace.shareWarningTitle": "更改已在工作区之间同步",
diff --git a/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts b/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts
new file mode 100644
index 0000000000000..fdf827a931054
--- /dev/null
+++ b/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts
@@ -0,0 +1,128 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import expect from '@kbn/expect';
+import { SuperTest } from 'supertest';
+import type { KibanaClient } from '@elastic/elasticsearch/api/kibana';
+import { LegacyUrlAlias } from 'src/core/server/saved_objects/object_types';
+import { SPACES } from '../lib/spaces';
+import { getUrlPrefix } from '../../../saved_object_api_integration/common/lib/saved_object_test_utils';
+import {
+ ExpectResponseBody,
+ TestDefinition,
+ TestSuite,
+} from '../../../saved_object_api_integration/common/lib/types';
+
+export interface DisableLegacyUrlAliasesTestDefinition extends TestDefinition {
+ request: {
+ aliases: Array<{ targetSpace: string; targetType: string; sourceId: string }>;
+ };
+}
+export type DisableLegacyUrlAliasesTestSuite = TestSuite;
+export interface DisableLegacyUrlAliasesTestCase {
+ targetSpace: string;
+ targetType: string;
+ sourceId: string;
+}
+
+const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias';
+interface RawLegacyUrlAlias {
+ [LEGACY_URL_ALIAS_TYPE]: LegacyUrlAlias;
+}
+
+export const TEST_CASE_TARGET_TYPE = 'sharedtype';
+export const TEST_CASE_SOURCE_ID = 'default_only'; // two aliases exist for default_only: one in space_1, and one in space_2
+const createRequest = (alias: DisableLegacyUrlAliasesTestCase) => ({
+ aliases: [alias],
+});
+const getTestTitle = ({ targetSpace, targetType, sourceId }: DisableLegacyUrlAliasesTestCase) => {
+ return `for alias '${targetSpace}:${targetType}:${sourceId}'`;
+};
+
+export function disableLegacyUrlAliasesTestSuiteFactory(
+ es: KibanaClient,
+ esArchiver: any,
+ supertest: SuperTest
+) {
+ const expectResponseBody = (
+ testCase: DisableLegacyUrlAliasesTestCase,
+ statusCode: 204 | 403
+ ): ExpectResponseBody => async (response: Record) => {
+ if (statusCode === 403) {
+ expect(response.body).to.eql({
+ statusCode: 403,
+ error: 'Forbidden',
+ message: `Unable to disable aliases for ${testCase.targetType}`,
+ });
+ }
+ const { targetSpace, targetType, sourceId } = testCase;
+ const esResponse = await es.get({
+ index: '.kibana',
+ id: `${LEGACY_URL_ALIAS_TYPE}:${targetSpace}:${targetType}:${sourceId}`,
+ });
+ const doc = esResponse.body._source!;
+ expect(doc).not.to.be(undefined);
+ expect(doc[LEGACY_URL_ALIAS_TYPE].disabled).to.be(statusCode === 204 ? true : undefined);
+ };
+ const createTestDefinitions = (
+ testCases: DisableLegacyUrlAliasesTestCase | DisableLegacyUrlAliasesTestCase[],
+ forbidden: boolean,
+ options: {
+ responseBodyOverride?: ExpectResponseBody;
+ } = {}
+ ): DisableLegacyUrlAliasesTestDefinition[] => {
+ const cases = Array.isArray(testCases) ? testCases : [testCases];
+ const responseStatusCode = forbidden ? 403 : 204;
+ return cases.map((x) => ({
+ title: getTestTitle(x),
+ responseStatusCode,
+ request: createRequest(x),
+ responseBody: options?.responseBodyOverride || expectResponseBody(x, responseStatusCode),
+ }));
+ };
+
+ const makeDisableLegacyUrlAliasesTest = (describeFn: Mocha.SuiteFunction) => (
+ description: string,
+ definition: DisableLegacyUrlAliasesTestSuite
+ ) => {
+ const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition;
+
+ describeFn(description, () => {
+ before(() =>
+ esArchiver.load(
+ 'x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces'
+ )
+ );
+ after(() =>
+ esArchiver.unload(
+ 'x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces'
+ )
+ );
+
+ for (const test of tests) {
+ it(`should return ${test.responseStatusCode} ${test.title}`, async () => {
+ const requestBody = test.request;
+ await supertest
+ .post(`${getUrlPrefix(spaceId)}/api/spaces/_disable_legacy_url_aliases`)
+ .auth(user?.username, user?.password)
+ .send(requestBody)
+ .expect(test.responseStatusCode)
+ .then(test.responseBody);
+ });
+ }
+ });
+ };
+
+ const addTests = makeDisableLegacyUrlAliasesTest(describe);
+ // @ts-ignore
+ addTests.only = makeDisableLegacyUrlAliasesTest(describe.only);
+
+ return {
+ addTests,
+ createTestDefinitions,
+ };
+}
diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/disable_legacy_url_aliases.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/disable_legacy_url_aliases.ts
new file mode 100644
index 0000000000000..c89bc519468ad
--- /dev/null
+++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/disable_legacy_url_aliases.ts
@@ -0,0 +1,80 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { SPACES } from '../../common/lib/spaces';
+import { getTestScenarios } from '../../../saved_object_api_integration/common/lib/saved_object_test_utils';
+import { TestUser } from '../../../saved_object_api_integration/common/lib/types';
+import {
+ disableLegacyUrlAliasesTestSuiteFactory,
+ DisableLegacyUrlAliasesTestCase,
+ TEST_CASE_TARGET_TYPE,
+ TEST_CASE_SOURCE_ID,
+ DisableLegacyUrlAliasesTestDefinition,
+} from '../../common/suites/disable_legacy_url_aliases';
+import { FtrProviderContext } from '../../common/ftr_provider_context';
+
+const {
+ SPACE_1: { spaceId: SPACE_1_ID },
+ SPACE_2: { spaceId: SPACE_2_ID },
+} = SPACES;
+
+const createTestCases = (...spaceIds: string[]): DisableLegacyUrlAliasesTestCase[] => {
+ return spaceIds.map((targetSpace) => ({
+ targetSpace,
+ targetType: TEST_CASE_TARGET_TYPE,
+ sourceId: TEST_CASE_SOURCE_ID,
+ }));
+};
+
+// eslint-disable-next-line import/no-default-export
+export default function ({ getService }: FtrProviderContext) {
+ const supertest = getService('supertestWithoutAuth');
+ const esArchiver = getService('esArchiver');
+ const es = getService('es');
+
+ const { addTests, createTestDefinitions } = disableLegacyUrlAliasesTestSuiteFactory(
+ es,
+ esArchiver,
+ supertest
+ );
+
+ describe('_disable_legacy_url_aliases', () => {
+ const _addTests = (user: TestUser, tests: DisableLegacyUrlAliasesTestDefinition[]) => {
+ addTests(`${user.description}`, { user, tests });
+ };
+ getTestScenarios().security.forEach(({ users }) => {
+ // We are intentionally using "security" test scenarios here, *not* "securityAndSpaces", because of how these tests are structured.
+
+ [
+ users.noAccess,
+ users.legacyAll,
+ users.dualRead,
+ users.readGlobally,
+ users.allAtDefaultSpace,
+ users.readAtDefaultSpace,
+ users.readAtSpace1,
+ ].forEach((user) => {
+ const unauthorized = createTestDefinitions(createTestCases(SPACE_1_ID, SPACE_2_ID), true);
+ _addTests(user, unauthorized);
+ });
+
+ const authorizedSpace1 = [
+ ...createTestDefinitions(createTestCases(SPACE_1_ID), false),
+ ...createTestDefinitions(createTestCases(SPACE_2_ID), true),
+ ];
+ _addTests(users.allAtSpace1, authorizedSpace1);
+
+ [users.dualAll, users.allGlobally, users.superuser].forEach((user) => {
+ const authorizedGlobally = createTestDefinitions(
+ createTestCases(SPACE_1_ID, SPACE_2_ID),
+ false
+ );
+ _addTests(user, authorizedGlobally);
+ });
+ });
+ });
+}
diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts
index 4bb4d10eaabf8..a86fef0d758fc 100644
--- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts
+++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts
@@ -29,5 +29,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) {
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./update_objects_spaces'));
+ loadTestFile(require.resolve('./disable_legacy_url_aliases'));
});
}
diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/disable_legacy_url_aliases.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/disable_legacy_url_aliases.ts
new file mode 100644
index 0000000000000..4cb73a7849b43
--- /dev/null
+++ b/x-pack/test/spaces_api_integration/spaces_only/apis/disable_legacy_url_aliases.ts
@@ -0,0 +1,45 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { SPACES } from '../../common/lib/spaces';
+import {
+ disableLegacyUrlAliasesTestSuiteFactory,
+ DisableLegacyUrlAliasesTestCase,
+ TEST_CASE_TARGET_TYPE,
+ TEST_CASE_SOURCE_ID,
+} from '../../common/suites/disable_legacy_url_aliases';
+import { FtrProviderContext } from '../../common/ftr_provider_context';
+
+const {
+ SPACE_1: { spaceId: SPACE_1_ID },
+ SPACE_2: { spaceId: SPACE_2_ID },
+} = SPACES;
+
+const createTestCases = (...spaceIds: string[]): DisableLegacyUrlAliasesTestCase[] => {
+ return spaceIds.map((targetSpace) => ({
+ targetSpace,
+ targetType: TEST_CASE_TARGET_TYPE,
+ sourceId: TEST_CASE_SOURCE_ID,
+ }));
+};
+
+// eslint-disable-next-line import/no-default-export
+export default function ({ getService }: FtrProviderContext) {
+ const supertest = getService('supertest');
+ const esArchiver = getService('esArchiver');
+ const es = getService('es');
+
+ const { addTests, createTestDefinitions } = disableLegacyUrlAliasesTestSuiteFactory(
+ es,
+ esArchiver,
+ supertest
+ );
+
+ const testCases = createTestCases(SPACE_1_ID, SPACE_2_ID);
+ const tests = createTestDefinitions(testCases, false);
+ addTests(`_disable_legacy_url_aliases`, { tests });
+}
diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts
index 489e2c2d22ffa..f64336b2b4908 100644
--- a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts
+++ b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts
@@ -21,5 +21,6 @@ export default function spacesOnlyTestSuite({ loadTestFile }: FtrProviderContext
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./update_objects_spaces'));
+ loadTestFile(require.resolve('./disable_legacy_url_aliases'));
});
}