From 8cdf17178af17469ac86ce38963e44f7aa908c58 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 27 Aug 2020 13:57:28 -0400 Subject: [PATCH 01/25] Change `delete` to always delete the object This method will no longer update a multi-namespace object to remove it from its current namespace. Instead, it will always delete the object, even if that object exists in multiple namespaces. Adds a new `force` option that is required if the object does exist in multiple namespaces. --- docs/api/saved-objects/delete.asciidoc | 8 ++++ ...-server.savedobjectsdeleteoptions.force.md | 13 +++++++ ...n-core-server.savedobjectsdeleteoptions.md | 1 + .../server/saved_objects/routes/delete.ts | 6 ++- .../routes/integration_tests/delete.test.ts | 15 +++++++- .../service/lib/repository.test.js | 37 ++++++------------- .../saved_objects/service/lib/repository.ts | 37 +++---------------- .../service/saved_objects_client.ts | 2 + src/core/server/server.api.md | 1 + .../common/suites/delete.ts | 20 +++++----- .../security_and_spaces/apis/delete.ts | 9 ++++- .../security_only/apis/delete.ts | 6 ++- .../spaces_only/apis/delete.ts | 9 ++++- 13 files changed, 91 insertions(+), 73 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.force.md diff --git a/docs/api/saved-objects/delete.asciidoc b/docs/api/saved-objects/delete.asciidoc index af587b0e7af10b..9c342cb4d843e9 100644 --- a/docs/api/saved-objects/delete.asciidoc +++ b/docs/api/saved-objects/delete.asciidoc @@ -27,6 +27,14 @@ WARNING: Once you delete a saved object, _it cannot be recovered_. `id`:: (Required, string) The object ID that you want to remove. +[[saved-objects-api-delete-query-params]] +==== Query parameters + +`force`:: + (Optional, boolean) When true, forces an object to be deleted if it exists in multiple namespaces. ++ +TIP: Use this if you attempted to delete an object and received an HTTP 400 error with the following message: _"Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway"_ + [[saved-objects-api-delete-response-codes]] ==== Response code diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.force.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.force.md new file mode 100644 index 00000000000000..f869d1f863a9f6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.force.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsDeleteOptions](./kibana-plugin-core-server.savedobjectsdeleteoptions.md) > [force](./kibana-plugin-core-server.savedobjectsdeleteoptions.force.md) + +## SavedObjectsDeleteOptions.force property + +Force deletion of an object that exists in multiple namespaces + +Signature: + +```typescript +force?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.md index 760c30edcdfb52..245819e44d37d6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.md @@ -15,5 +15,6 @@ export interface SavedObjectsDeleteOptions extends SavedObjectsBaseOptions | Property | Type | Description | | --- | --- | --- | +| [force](./kibana-plugin-core-server.savedobjectsdeleteoptions.force.md) | boolean | Force deletion of an object that exists in multiple namespaces | | [refresh](./kibana-plugin-core-server.savedobjectsdeleteoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | diff --git a/src/core/server/saved_objects/routes/delete.ts b/src/core/server/saved_objects/routes/delete.ts index d1194553362122..d99397d2a050c6 100644 --- a/src/core/server/saved_objects/routes/delete.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -29,11 +29,15 @@ export const registerDeleteRoute = (router: IRouter) => { type: schema.string(), id: schema.string(), }), + query: schema.object({ + force: schema.maybe(schema.boolean()), + }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { type, id } = req.params; - const result = await context.core.savedObjects.client.delete(type, id); + const { force } = req.query; + const result = await context.core.savedObjects.client.delete(type, id, { force }); return res.ok({ body: result }); }) ); diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts index a58f400ec3e1de..ff8642a34929fb 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -58,6 +58,19 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => { .delete('/api/saved_objects/index-pattern/logstash-*') .expect(200); - expect(savedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', 'logstash-*'); + expect(savedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', 'logstash-*', { + force: undefined, + }); + }); + + it('can specify `force` option', async () => { + await supertest(httpSetup.server.listener) + .delete('/api/saved_objects/index-pattern/logstash-*') + .query({ force: true }) + .expect(200); + + expect(savedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', 'logstash-*', { + force: true, + }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 0e72ad2fec06cd..1dafd93d579466 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -2043,31 +2043,17 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES delete action when not using a multi-namespace type`, async () => { await deleteSuccess(type, id); + expect(client.get).not.toHaveBeenCalled(); expect(client.delete).toHaveBeenCalledTimes(1); }); - it(`should use ES get action then delete action when using a multi-namespace type with no namespaces remaining`, async () => { + it(`should use ES get action then delete action when using a multi-namespace type`, async () => { await deleteSuccess(MULTI_NAMESPACE_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); expect(client.delete).toHaveBeenCalledTimes(1); }); - it(`should use ES get action then update action when using a multi-namespace type with one or more namespaces remaining`, async () => { - const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); - mockResponse._source.namespaces = ['default', 'some-other-nameespace']; - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) - ); - client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'updated' }) - ); - - await savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id); - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.update).toHaveBeenCalledTimes(1); - }); - - it(`includes the version of the existing document when type is multi-namespace`, async () => { + it(`includes the version of the existing document when using a multi-namespace type`, async () => { await deleteSuccess(MULTI_NAMESPACE_TYPE, id); const versionProperties = { if_seq_no: mockVersionProps._seq_no, @@ -2169,19 +2155,18 @@ describe('SavedObjectsRepository', () => { expect(client.get).toHaveBeenCalledTimes(1); }); - it(`throws when ES is unable to find the document during update`, async () => { - const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); - mockResponse._source.namespaces = ['default', 'some-other-nameespace']; + it(`throws when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => { + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + response._source.namespaces = [namespace, 'bar-namespace']; client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + await expect( + savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + ).rejects.toThrowError( + 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); - - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); - expect(client.update).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during delete`, async () => { diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index a83c86e5856289..a40fce401ec1e1 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -553,7 +553,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { refresh = DEFAULT_REFRESH_SETTING } = options; + const { refresh = DEFAULT_REFRESH_SETTING, force } = options; const namespace = normalizeNamespace(options.namespace); const rawId = this._serializer.generateRawId(namespace, type, id); @@ -561,38 +561,11 @@ export class SavedObjectsRepository { if (this._registry.isMultiNamespace(type)) { preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); - const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult); - const remainingNamespaces = existingNamespaces?.filter( - (x) => x !== SavedObjectsUtils.namespaceIdToString(namespace) - ); - - if (remainingNamespaces?.length) { - // if there is 1 or more namespace remaining, update the saved object - const time = this._getCurrentTime(); - - const doc = { - updated_at: time, - namespaces: remainingNamespaces, - }; - - const { statusCode } = await this.client.update( - { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - body: { - doc, - }, - }, - { ignore: [404] } + const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult) ?? []; + if (!force && existingNamespaces.length > 1) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); - - if (statusCode === 404) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return {}; } } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 8c96116de49cb7..edfbbc4c6b4083 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -211,6 +211,8 @@ export interface SavedObjectsBulkUpdateOptions extends SavedObjectsBaseOptions { export interface SavedObjectsDeleteOptions extends SavedObjectsBaseOptions { /** The Elasticsearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; + /** Force deletion of an object that exists in multiple namespaces */ + force?: boolean; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 450be3b0e9a6c2..cb7501dd5d47fb 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2002,6 +2002,7 @@ export interface SavedObjectsDeleteFromNamespacesResponse { // @public (undocumented) export interface SavedObjectsDeleteOptions extends SavedObjectsBaseOptions { + force?: boolean; refresh?: MutatingOperationRefreshSetting; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index 228e7977f99ac0..859bb2b7e8fe29 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -8,20 +8,16 @@ import { SuperTest } from 'supertest'; import expect from '@kbn/expect/expect.js'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; -import { - createRequest, - expectResponses, - getUrlPrefix, - getTestTitle, -} from '../lib/saved_object_test_utils'; +import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; export interface DeleteTestDefinition extends TestDefinition { - request: { type: string; id: string }; + request: { type: string; id: string; force?: boolean }; } export type DeleteTestSuite = TestSuite; export interface DeleteTestCase extends TestCase { - failure?: 403 | 404; + force?: boolean; + failure?: 400 | 403 | 404; } const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); @@ -30,6 +26,11 @@ export const TEST_CASES: Record = Object.freeze({ DOES_NOT_EXIST, }); +/** + * Test cases have additional properties that we don't want to send in HTTP Requests + */ +const createRequest = ({ type, id, force }: DeleteTestCase) => ({ type, id, force }); + export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('delete'); const expectResponseBody = (testCase: DeleteTestCase): ExpectResponseBody => async ( @@ -81,9 +82,10 @@ export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest { - const { type, id } = test.request; + const { type, id, force } = test.request; await supertest .delete(`${getUrlPrefix(spaceId)}/api/saved_objects/${type}/${id}`) + .query({ ...(force && { force }) }) .auth(user?.username, user?.password) .expect(test.responseStatusCode) .then(test.responseBody); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts index 62b229f8315622..eed67b6779679b 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -19,7 +19,7 @@ const { SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail404 } = testCaseFailures; +const { fail400, fail404 } = testCaseFailures; const createTestCases = (spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect @@ -30,6 +30,13 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail400(spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID), + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + // try to delete this object again, this time using the `force` option + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + force: true, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts index 4f379d5d1cbb9b..f1aee480c10617 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts @@ -13,7 +13,7 @@ import { DeleteTestDefinition, } from '../../common/suites/delete'; -const { fail404 } = testCaseFailures; +const { fail400, fail404 } = testCaseFailures; const createTestCases = () => { // for each permitted (non-403) outcome, if failure !== undefined then we expect @@ -22,7 +22,9 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, - CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + // try to delete this object again, this time using the `force` option + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, force: true }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts index 82045a9e288ced..eab089084ca940 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts @@ -14,7 +14,7 @@ const { SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail404 } = testCaseFailures; +const { fail400, fail404 } = testCaseFailures; const createTestCases = (spaceId: string) => [ // for each outcome, if failure !== undefined then we expect to receive @@ -24,6 +24,13 @@ const createTestCases = (spaceId: string) => [ { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail400(spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID), + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + // try to delete this object again, this time using the `force` option + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + force: true, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, From d978bce678a9971abf2289bae7e9f1a44e955696 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 10 Sep 2020 13:44:08 -0400 Subject: [PATCH 02/25] Change `SavedObjectsRepository` to support `'*'` namespace string This is treated as "all namespaces". --- .../service/lib/repository.test.js | 88 ++++++++++++++++++- .../saved_objects/service/lib/repository.ts | 45 ++++++++-- .../lib/search_dsl/query_params.test.ts | 4 +- .../service/lib/search_dsl/query_params.ts | 7 +- .../server/saved_objects/service/lib/utils.ts | 1 + ...ecure_saved_objects_client_wrapper.test.ts | 5 +- .../secure_saved_objects_client_wrapper.ts | 6 +- .../spaces/server/lib/utils/namespace.ts | 2 + .../routes/api/external/share_add_spaces.ts | 5 +- .../api/external/share_remove_spaces.ts | 5 +- .../spaces_saved_objects_client.ts | 4 +- .../saved_objects/spaces/data.json | 17 ++++ .../common/lib/saved_object_test_cases.ts | 7 +- .../common/lib/saved_object_test_utils.ts | 7 +- .../common/lib/spaces.ts | 2 + .../common/suites/export.ts | 19 ++-- .../common/suites/find.ts | 7 +- .../security_and_spaces/apis/bulk_create.ts | 1 + .../security_and_spaces/apis/bulk_get.ts | 1 + .../security_and_spaces/apis/bulk_update.ts | 2 + .../security_and_spaces/apis/create.ts | 1 + .../security_and_spaces/apis/delete.ts | 3 + .../security_and_spaces/apis/get.ts | 1 + .../security_and_spaces/apis/import.ts | 1 + .../apis/resolve_import_errors.ts | 1 + .../security_and_spaces/apis/update.ts | 1 + .../security_only/apis/bulk_create.ts | 1 + .../security_only/apis/bulk_get.ts | 1 + .../security_only/apis/bulk_update.ts | 2 + .../security_only/apis/create.ts | 1 + .../security_only/apis/delete.ts | 3 + .../security_only/apis/get.ts | 1 + .../security_only/apis/import.ts | 1 + .../apis/resolve_import_errors.ts | 1 + .../security_only/apis/update.ts | 1 + .../spaces_only/apis/bulk_create.ts | 1 + .../spaces_only/apis/bulk_get.ts | 1 + .../spaces_only/apis/bulk_update.ts | 2 + .../spaces_only/apis/create.ts | 1 + .../spaces_only/apis/delete.ts | 3 + .../spaces_only/apis/get.ts | 1 + .../spaces_only/apis/import.ts | 1 + .../spaces_only/apis/resolve_import_errors.ts | 1 + .../spaces_only/apis/update.ts | 1 + .../saved_objects/spaces/data.json | 19 +++- .../common/lib/saved_object_test_cases.ts | 6 +- .../common/suites/copy_to_space.ts | 2 +- .../suites/resolve_copy_to_space_conflicts.ts | 2 +- .../common/suites/share_add.ts | 14 +-- .../common/suites/share_remove.ts | 14 +-- .../security_and_spaces/apis/share_add.ts | 35 ++++++-- .../security_and_spaces/apis/share_remove.ts | 14 +-- .../spaces_only/apis/share_add.ts | 33 +++++-- .../spaces_only/apis/share_remove.ts | 18 +++- 54 files changed, 338 insertions(+), 86 deletions(-) diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 1dafd93d579466..0b4b81d8e89bf9 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -20,6 +20,7 @@ import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; import { SavedObjectsErrorHelpers } from './errors'; +import { ALL_NAMESPACES_STRING } from './utils'; import { SavedObjectsSerializer } from '../../serialization'; import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; @@ -725,6 +726,12 @@ describe('SavedObjectsRepository', () => { }); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`returns error when type is invalid`, async () => { const obj = { ...obj3, type: 'unknownType' }; await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); @@ -1042,6 +1049,13 @@ describe('SavedObjectsRepository', () => { }); }; + it(`throws when options.namespace is '*'`, async () => { + const obj = { type: 'dashboard', id: 'three' }; + await expect( + savedObjectsRepository.bulkGet([obj], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`returns error when type is invalid`, async () => { const obj = { type: 'unknownType', id: 'three' }; await bulkGetErrorInvalidType([obj1, obj, obj2]); @@ -1467,6 +1481,12 @@ describe('SavedObjectsRepository', () => { }); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.bulkUpdate([obj], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`returns error when type is invalid`, async () => { const _obj = { ...obj, type: 'unknownType' }; await bulkUpdateError(_obj, undefined, expectErrorNotFound(_obj)); @@ -1477,6 +1497,15 @@ describe('SavedObjectsRepository', () => { await bulkUpdateError(_obj, undefined, expectErrorNotFound(_obj)); }); + it(`returns error when object namespace is '*'`, async () => { + const _obj = { ...obj, namespace: '*' }; + await bulkUpdateError( + _obj, + undefined, + expectErrorResult(obj, createBadRequestError('"namespace" cannot be "*"')) + ); + }); + it(`returns error when ES is unable to find the document (mget)`, async () => { const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE, found: false }; const mgetResponse = getMockMgetResponse([_obj]); @@ -1630,7 +1659,7 @@ describe('SavedObjectsRepository', () => { ); }; - describe('cluster calls', () => { + describe('client calls', () => { it(`doesn't make a cluster call if the objects array is empty`, async () => { await checkConflicts([]); expect(client.mget).not.toHaveBeenCalled(); @@ -1662,6 +1691,14 @@ describe('SavedObjectsRepository', () => { }); }); + describe('errors', () => { + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.checkConflicts([obj1], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + }); + describe('returns', () => { it(`expected results`, async () => { const unknownTypeObj = { type: 'unknownType', id: 'three' }; @@ -1909,6 +1946,12 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.create(type, attributes, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`throws when type is invalid`, async () => { await expect(savedObjectsRepository.create('unknownType', attributes)).rejects.toThrowError( createUnsupportedTypeError('unknownType') @@ -2120,6 +2163,12 @@ describe('SavedObjectsRepository', () => { ); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.delete(type, id, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); expect(client.delete).not.toHaveBeenCalled(); @@ -2169,6 +2218,20 @@ describe('SavedObjectsRepository', () => { expect(client.get).toHaveBeenCalledTimes(1); }); + it(`throws when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => { + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + response._source.namespaces = ['*']; + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); + await expect( + savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + ).rejects.toThrowError( + 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' + ); + expect(client.get).toHaveBeenCalledTimes(1); + }); + it(`throws when ES is unable to find the document during delete`, async () => { client.delete.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'not_found' }) @@ -2252,7 +2315,7 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when namespace is not a string`, async () => { + it(`throws when namespace is not a string or is '*'`, async () => { const test = async (namespace) => { await expect(savedObjectsRepository.deleteByNamespace(namespace)).rejects.toThrowError( `namespace is required, and must be a string` @@ -2263,6 +2326,7 @@ describe('SavedObjectsRepository', () => { await test(['namespace']); await test(123); await test(true); + await test(ALL_NAMESPACES_STRING); }); }); @@ -2861,6 +2925,12 @@ describe('SavedObjectsRepository', () => { ); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.get(type, id, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); expect(client.get).not.toHaveBeenCalled(); @@ -3052,6 +3122,14 @@ describe('SavedObjectsRepository', () => { ); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.incrementCounter(type, id, field, { + namespace: ALL_NAMESPACES_STRING, + }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`throws when type is not a string`, async () => { const test = async (type) => { await expect( @@ -3708,6 +3786,12 @@ describe('SavedObjectsRepository', () => { ); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.update(type, id, attributes, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); expect(client.update).not.toHaveBeenCalled(); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index a40fce401ec1e1..1feedd6c3bd787 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -67,7 +67,12 @@ import { } from '../../types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; -import { FIND_DEFAULT_PAGE, FIND_DEFAULT_PER_PAGE, SavedObjectsUtils } from './utils'; +import { + ALL_NAMESPACES_STRING, + FIND_DEFAULT_PAGE, + FIND_DEFAULT_PER_PAGE, + SavedObjectsUtils, +} from './utils'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -562,7 +567,10 @@ export class SavedObjectsRepository { if (this._registry.isMultiNamespace(type)) { preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult) ?? []; - if (!force && existingNamespaces.length > 1) { + if ( + !force && + (existingNamespaces.length > 1 || existingNamespaces.includes(ALL_NAMESPACES_STRING)) + ) { throw SavedObjectsErrorHelpers.createBadRequestError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -610,8 +618,8 @@ export class SavedObjectsRepository { namespace: string, options: SavedObjectsDeleteByNamespaceOptions = {} ): Promise { - if (!namespace || typeof namespace !== 'string') { - throw new TypeError(`namespace is required, and must be a string`); + if (!namespace || typeof namespace !== 'string' || namespace === '*') { + throw new TypeError(`namespace is required, and must be a string that is not equal to '*'`); } const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); @@ -1226,6 +1234,19 @@ export class SavedObjectsRepository { } const { attributes, references, version, namespace: objectNamespace } = object; + + if (objectNamespace === ALL_NAMESPACES_STRING) { + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: errorContent( + SavedObjectsErrorHelpers.createBadRequestError('"namespace" cannot be "*"') + ), + }, + }; + } // `objectNamespace` is a namespace string, while `namespace` is a namespace ID. // The object namespace string, if defined, will supersede the operation's namespace ID. @@ -1541,7 +1562,10 @@ export class SavedObjectsRepository { } const namespaces = raw._source.namespaces; - return namespaces?.includes(SavedObjectsUtils.namespaceIdToString(namespace)) ?? false; + const existsInNamespace = + namespaces?.includes(SavedObjectsUtils.namespaceIdToString(namespace)) || + namespaces?.includes('*'); + return existsInNamespace ?? false; } /** @@ -1668,8 +1692,15 @@ function getSavedObjectNamespaces( * Ensure that a namespace is always in its namespace ID representation. * This allows `'default'` to be used interchangeably with `undefined`. */ -const normalizeNamespace = (namespace?: string) => - namespace === undefined ? namespace : SavedObjectsUtils.namespaceStringToId(namespace); +const normalizeNamespace = (namespace?: string) => { + if (namespace === ALL_NAMESPACES_STRING) { + throw SavedObjectsErrorHelpers.createBadRequestError('"options.namespace" cannot be "*"'); + } else if (namespace === undefined) { + return namespace; + } else { + return SavedObjectsUtils.namespaceStringToId(namespace); + } +}; /** * Extracts the contents of a decorated error to return the attributes for bulk operations. diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index e13c67a7204000..330fa5066051f2 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -22,6 +22,7 @@ import { esKuery } from '../../../es_query'; type KueryNode = any; import { typeRegistryMock } from '../../../saved_objects_type_registry.mock'; +import { ALL_NAMESPACES_STRING } from '../utils'; import { getQueryParams } from './query_params'; const registry = typeRegistryMock.create(); @@ -52,9 +53,10 @@ const ALL_TYPE_SUBSETS = ALL_TYPES.reduce( const createTypeClause = (type: string, namespaces?: string[]) => { if (registry.isMultiNamespace(type)) { + const array = [...(namespaces ?? ['default']), ALL_NAMESPACES_STRING]; return { bool: { - must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]), + must: expect.arrayContaining([{ terms: { namespaces: array } }]), must_not: [{ exists: { field: 'namespace' } }], }, }; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index eaddc05fa921c1..8bd9c7d8312eea 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -22,7 +22,7 @@ type KueryNode = any; import { getRootPropertiesObjects, IndexMapping } from '../../../mappings'; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; -import { DEFAULT_NAMESPACE_STRING } from '../utils'; +import { ALL_NAMESPACES_STRING, DEFAULT_NAMESPACE_STRING } from '../utils'; /** * Gets the types based on the type. Uses mappings to support @@ -84,7 +84,10 @@ function getClauseForType( if (registry.isMultiNamespace(type)) { return { bool: { - must: [{ term: { type } }, { terms: { namespaces } }], + must: [ + { term: { type } }, + { terms: { namespaces: [...namespaces, ALL_NAMESPACES_STRING] } }, + ], must_not: [{ exists: { field: 'namespace' } }], }, }; diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index 3efe8614da1d79..69abc370892184 100644 --- a/src/core/server/saved_objects/service/lib/utils.ts +++ b/src/core/server/saved_objects/service/lib/utils.ts @@ -21,6 +21,7 @@ import { SavedObjectsFindOptions } from '../../types'; import { SavedObjectsFindResponse } from '..'; export const DEFAULT_NAMESPACE_STRING = 'default'; +export const ALL_NAMESPACES_STRING = '*'; export const FIND_DEFAULT_PAGE = 1; export const FIND_DEFAULT_PER_PAGE = 20; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 86d1b68ba761ed..491c44f28bed73 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -154,7 +154,7 @@ const expectObjectNamespaceFiltering = async ( ); const authorizedNamespace = args.options?.namespace || 'default'; - const namespaces = ['some-other-namespace', authorizedNamespace]; + const namespaces = ['some-other-namespace', '*', authorizedNamespace]; const returnValue = { namespaces, foo: 'bar' }; // we don't know which base client method will be called; mock them all clientOpts.baseClient.create.mockReturnValue(returnValue as any); @@ -164,7 +164,8 @@ const expectObjectNamespaceFiltering = async ( clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(returnValue as any); const result = await fn.bind(client)(...Object.values(args)); - expect(result).toEqual(expect.objectContaining({ namespaces: [authorizedNamespace, '?'] })); + // we will never redact the "All Spaces" ID + expect(result).toEqual(expect.objectContaining({ namespaces: ['*', authorizedNamespace, '?'] })); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes( privilegeChecks + 1 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 f5de8f4b226f34..a71fd856a611f2 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 @@ -55,6 +55,8 @@ interface EnsureAuthorizedTypeResult { isGloballyAuthorized?: boolean; } +const ALL_SPACES_ID = '*'; + export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { private readonly actions: Actions; private readonly auditLogger: PublicMethodsOf; @@ -383,7 +385,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } private redactAndSortNamespaces(spaceIds: string[], privilegeMap: Record) { - return spaceIds.map((x) => (privilegeMap[x] ? x : '?')).sort(namespaceComparator); + return spaceIds + .map((x) => (x === ALL_SPACES_ID || privilegeMap[x] ? x : '?')) + .sort(namespaceComparator); } private async redactSavedObjectNamespaces( diff --git a/x-pack/plugins/spaces/server/lib/utils/namespace.ts b/x-pack/plugins/spaces/server/lib/utils/namespace.ts index 344da18846f3b7..a34796d3720ae6 100644 --- a/x-pack/plugins/spaces/server/lib/utils/namespace.ts +++ b/x-pack/plugins/spaces/server/lib/utils/namespace.ts @@ -6,6 +6,8 @@ import { SavedObjectsUtils } from '../../../../../../src/core/server'; +export const ALL_SPACES_STRING = '*'; + /** * Converts a Space ID string to its namespace ID representation. Note that a Space ID string is equivalent to a namespace string. * diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts index ee61ccd2d5e410..3f4e439a8d683e 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts @@ -9,6 +9,7 @@ import { wrapError } from '../../../lib/errors'; import { ExternalRouteDeps } from '.'; import { SPACE_ID_REGEX } from '../../../lib/space_schema'; import { createLicensedRouteHandler } from '../../lib'; +import { ALL_SPACES_STRING } from '../../../lib/utils/namespace'; const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); export function initShareAddSpacesApi(deps: ExternalRouteDeps) { @@ -22,8 +23,8 @@ export function initShareAddSpacesApi(deps: ExternalRouteDeps) { spaces: schema.arrayOf( schema.string({ validate: (value) => { - if (!SPACE_ID_REGEX.test(value)) { - return `lower case, a-z, 0-9, "_", and "-" are allowed`; + if (value !== ALL_SPACES_STRING && !SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed, OR "*"`; } }, }), diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts index d03185ea7aa095..e2e261ef5b8279 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts @@ -9,6 +9,7 @@ import { wrapError } from '../../../lib/errors'; import { ExternalRouteDeps } from '.'; import { SPACE_ID_REGEX } from '../../../lib/space_schema'; import { createLicensedRouteHandler } from '../../lib'; +import { ALL_SPACES_STRING } from '../../../lib/utils/namespace'; const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); export function initShareRemoveSpacesApi(deps: ExternalRouteDeps) { @@ -22,8 +23,8 @@ export function initShareRemoveSpacesApi(deps: ExternalRouteDeps) { spaces: schema.arrayOf( schema.string({ validate: (value) => { - if (!SPACE_ID_REGEX.test(value)) { - return `lower case, a-z, 0-9, "_", and "-" are allowed`; + if (value !== ALL_SPACES_STRING && !SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed, OR "*"`; } }, }), diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index a65e0431aef920..49c2df0a40ce8d 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -21,7 +21,7 @@ import { ISavedObjectTypeRegistry, } from '../../../../../src/core/server'; import { SpacesServiceSetup } from '../spaces_service/spaces_service'; -import { spaceIdToNamespace } from '../lib/utils/namespace'; +import { ALL_SPACES_STRING, spaceIdToNamespace } from '../lib/utils/namespace'; import { SpacesClient } from '../lib/spaces_client'; interface SpacesSavedObjectsClientOptions { @@ -169,7 +169,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { try { const availableSpaces = await spacesClient.getAll('findSavedObjects'); - if (namespaces.includes('*')) { + if (namespaces.includes(ALL_SPACES_STRING)) { namespaces = availableSpaces.map((space) => space.id); } else { namespaces = namespaces.filter((namespace) => diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 4c0447c29c8f9c..d9d5c6f9c58081 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -347,6 +347,23 @@ } } +{ + "type": "doc", + "value": { + "id": "sharedtype:all_spaces", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["*"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + { "type": "doc", "value": { diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts index 190b12e038b276..e8558acc2c1f7d 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SPACES } from './spaces'; +import { SPACES, ALL_SPACES_ID } from './spaces'; import { TestCase } from './types'; const { @@ -32,6 +32,11 @@ export const SAVED_OBJECT_TEST_CASES: Record = Object.fr id: 'space2-isolatedtype-id', expectedNamespaces: [SPACE_2_ID], }), + MULTI_NAMESPACE_ALL_SPACES: Object.freeze({ + type: 'sharedtype', + id: 'all_spaces', + expectedNamespaces: [ALL_SPACES_ID], + }), MULTI_NAMESPACE_DEFAULT_AND_SPACE_1: Object.freeze({ type: 'sharedtype', id: 'default_and_space_1', diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index 9d4b5e80e9c3db..395a343a2af1ee 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; -import { SPACES } from './spaces'; +import { SPACES, ALL_SPACES_ID } from './spaces'; import { AUTHENTICATION } from './authentication'; import { TestCase, TestUser, ExpectResponseBody } from './types'; @@ -73,7 +73,10 @@ export const getTestTitle = ( }; export const isUserAuthorizedAtSpace = (user: TestUser | undefined, namespace: string) => - !user || user.authorizedAtSpaces.includes('*') || user.authorizedAtSpaces.includes(namespace); + !user || + namespace === ALL_SPACES_ID || + user.authorizedAtSpaces.includes('*') || + user.authorizedAtSpaces.includes(namespace); export const getRedactedNamespaces = ( user: TestUser | undefined, diff --git a/x-pack/test/saved_object_api_integration/common/lib/spaces.ts b/x-pack/test/saved_object_api_integration/common/lib/spaces.ts index a9c552d4ccd789..7b21c2f0245fa8 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/spaces.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/spaces.ts @@ -15,3 +15,5 @@ export const SPACES = { spaceId: 'default', }, }; + +export const ALL_SPACES_ID = '*'; diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index 4eb967a952c604..a1addda1cdd1ff 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -75,14 +75,17 @@ export const getTestCases = (spaceId?: string): { [key: string]: ExportTestCase multiNamespaceType: { title: 'multi-namespace type', type: 'sharedtype', - successResult: (spaceId === SPACE_1_ID - ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] - : spaceId === SPACE_2_ID - ? [CASES.MULTI_NAMESPACE_ONLY_SPACE_2] - : [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1] - ) - .concat([CONFLICT_1_OBJ, CONFLICT_2A_OBJ, CONFLICT_2B_OBJ, CONFLICT_3_OBJ, CONFLICT_4A_OBJ]) - .flat(), + successResult: [ + CASES.MULTI_NAMESPACE_ALL_SPACES, + ...(spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? [CASES.MULTI_NAMESPACE_ONLY_SPACE_2] + : [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1] + ) + .concat([CONFLICT_1_OBJ, CONFLICT_2A_OBJ, CONFLICT_2B_OBJ, CONFLICT_3_OBJ, CONFLICT_4A_OBJ]) + .flat(), + ], }, namespaceAgnosticObject: { title: 'namespace-agnostic object', diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index 381306f8101223..c7243d8db25fe7 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import querystring from 'querystring'; import { SAVED_OBJECT_TEST_CASES, CONFLICT_TEST_CASES } from '../lib/saved_object_test_cases'; -import { SPACES } from '../lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../lib/spaces'; import { getUrlPrefix, isUserAuthorizedAtSpace, @@ -75,13 +75,16 @@ export const getTestCases = ( return TEST_CASES.filter((t) => { const hasOtherNamespaces = !t.expectedNamespaces || // namespace-agnostic types do not have an expectedNamespaces field - t.expectedNamespaces.some((ns) => ns !== (currentSpace ?? DEFAULT_SPACE_ID)); + t.expectedNamespaces.some( + (ns) => ns === ALL_SPACES_ID || ns !== (currentSpace ?? DEFAULT_SPACE_ID) + ); return hasOtherNamespaces && predicate(t); }); } return TEST_CASES.filter( (t) => (!t.expectedNamespaces || + t.expectedNamespaces.includes(ALL_SPACES_ID) || t.expectedNamespaces.includes(currentSpace ?? DEFAULT_SPACE_ID)) && predicate(t) ); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index 93ae439d011667..e0faae9a074b09 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -43,6 +43,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), expectedNamespaces, }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts index 4a35bdd03e4dd5..4878d9d81dbf69 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts @@ -28,6 +28,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts index 1e11d1fc61110f..3f4341de6cfc56 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts @@ -28,6 +28,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), @@ -44,6 +45,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespace: DEFAULT_SPACE_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespace: SPACE_1_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespace: SPACE_1_ID, ...fail404() }, // intentional 404 test case + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, namespace: DEFAULT_SPACE_ID }, // any spaceId will work (not '*') { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespace: DEFAULT_SPACE_ID }, // SPACE_1_ID would also work { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespace: SPACE_2_ID, ...fail404() }, // intentional 404 test case { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespace: SPACE_2_ID }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index 7353dafb5e1b5b..7bc3e027bfadee 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -41,6 +41,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), expectedNamespaces, }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts index eed67b6779679b..436f09e8d2ee01 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -28,6 +28,9 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail400() }, + // try to delete this object again, this time using the `force` option + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, force: true }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400(spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts index dabe174af4d4be..b554eb55b0adb1 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts @@ -28,6 +28,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index 0b531a3dccc1ab..a319a73c6a98ab 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -58,6 +58,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { const group2 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes CASES.NEW_MULTI_NAMESPACE_OBJ, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index 792fe63e5932d7..b0f4f13f268c9e 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -55,6 +55,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; const group1All = [...group1Importable, ...group1NonImportable]; const group2 = [ + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts index fec6f2b7de7154..a976ce08adb1f2 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -28,6 +28,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index cc2c5e2e7fc005..71743b6267e6d6 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -28,6 +28,7 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, expectedNamespaces }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, expectedNamespaces }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(), ...unresolvableConflict() }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts index d305e08da1b327..96eddf1f8bd3cc 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts @@ -22,6 +22,7 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_ALL_SPACES, CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts index 39ceb5a70d1b23..2a19c56f80ce6f 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts @@ -28,6 +28,7 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_ALL_SPACES, CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, @@ -42,6 +43,7 @@ const createTestCases = () => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespace: DEFAULT_SPACE_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespace: SPACE_1_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespace: SPACE_1_ID, ...fail404() }, // intentional 404 test case + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, namespace: DEFAULT_SPACE_ID }, // any spaceId will work (not '*') { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespace: DEFAULT_SPACE_ID }, // SPACE_1_ID would also work { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespace: SPACE_2_ID, ...fail404() }, // intentional 404 test case { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespace: SPACE_2_ID }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index b7c6ecef979bda..e0847ac5fd08a3 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -27,6 +27,7 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, expectedNamespaces }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, expectedNamespaces }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts index f1aee480c10617..4caf112a59b272 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts @@ -22,6 +22,9 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail400() }, + // try to delete this object again, this time using the `force` option + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, force: true }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, // try to delete this object again, this time using the `force` option { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, force: true }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts index 0f105b939960f2..5eed2839b61722 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts @@ -22,6 +22,7 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_ALL_SPACES, CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts index 34be3b7408432a..df763dba6d18ab 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -49,6 +49,7 @@ const createTestCases = (overwrite: boolean) => { const group2 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes CASES.NEW_MULTI_NAMESPACE_OBJ, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...destinationId() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...destinationId() }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts index 91134dd14bd8a3..22734e95da0b59 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts @@ -43,6 +43,7 @@ const createTestCases = (overwrite: boolean) => { const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; const group1All = [...group1Importable, ...group1NonImportable]; const group2 = [ + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts index c1fd350101fd4e..d6e22abf4af246 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts @@ -22,6 +22,7 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_ALL_SPACES, CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index ef47b09eddbc83..7d9fcc8e46434a 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -39,6 +39,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), expectedNamespaces, }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts index 37bb2ec920c1e6..7eef9a95f52385 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts @@ -22,6 +22,7 @@ const createTestCases = (spaceId: string) => [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts index b51ec303fadf3f..e789377b93fe16 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts @@ -23,6 +23,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), @@ -39,6 +40,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespace: DEFAULT_SPACE_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespace: SPACE_1_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespace: SPACE_1_ID, ...fail404() }, // intentional 404 test case + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, namespace: spaceId }, // any spaceId will work (not '*') { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespace: DEFAULT_SPACE_ID }, // SPACE_1_ID would also work { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespace: SPACE_2_ID, ...fail404() }, // intentional 404 test case { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespace: SPACE_2_ID }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index 10e57b4db82dc7..2baf0414117e44 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -36,6 +36,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), expectedNamespaces, }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts index eab089084ca940..66309a4be44608 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts @@ -22,6 +22,9 @@ const createTestCases = (spaceId: string) => [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail400() }, + // try to delete this object again, this time using the `force` option + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, force: true }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400(spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts index b0fed3e13b9af8..a56216537a365f 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts @@ -22,6 +22,7 @@ const createTestCases = (spaceId: string) => [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index a36249528540b8..3009fa0bd75a4d 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -44,6 +44,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index 1431a61b1cbe07..721a6b2bf71080 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -48,6 +48,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { : CASES.SINGLE_NAMESPACE_SPACE_2; return [ { ...singleNamespaceObject, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts index 31ef6fb25b2f21..7a004290249ca6 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts @@ -22,6 +22,7 @@ const createTestCases = (spaceId: string) => [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 7e528c23c20a05..5ce6c0ce6b7c5b 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -482,7 +482,7 @@ { "type": "doc", "value": { - "id": "sharedtype:all_spaces", + "id": "sharedtype:each_space", "index": ".kibana", "source": { "sharedtype": { @@ -496,6 +496,23 @@ } } +{ + "type": "doc", + "value": { + "id": "sharedtype:all_spaces", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["*"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + { "type": "doc", "value": { diff --git a/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts index 3b0f5f8570aa32..9b8baa7f22a2bb 100644 --- a/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts @@ -29,9 +29,13 @@ export const MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES = Object.freeze({ id: 'space_1_and_space_2', existingNamespaces: ['space_1', 'space_2'], }), + EACH_SPACE: Object.freeze({ + id: 'each_space', + existingNamespaces: ['default', 'space_1', 'space_2'], // each individual space + }), ALL_SPACES: Object.freeze({ id: 'all_spaces', - existingNamespaces: ['default', 'space_1', 'space_2'], + existingNamespaces: ['*'], // all current and future spaces }), DOES_NOT_EXIST: Object.freeze({ id: 'does_not_exist', diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index 26c736034501f7..ee7a2fb7316571 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -423,7 +423,7 @@ export function copyToSpaceTestSuiteFactory( const type = 'sharedtype'; const v4 = new RegExp(/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i); const noConflictId = `${spaceId}_only`; - const exactMatchId = 'all_spaces'; + const exactMatchId = 'each_space'; const inexactMatchId = `conflict_1_${spaceId}`; const ambiguousConflictId = `conflict_2_${spaceId}`; diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index cb9219b1ba2ed8..eba7e2033eadf2 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -310,7 +310,7 @@ export function resolveCopyToSpaceConflictsSuite( // a 403 error actually comes back as an HTTP 200 response const statusCode = outcome === 'noAccess' ? 404 : 200; const type = 'sharedtype'; - const exactMatchId = 'all_spaces'; + const exactMatchId = 'each_space'; const inexactMatchId = `conflict_1_${spaceId}`; const ambiguousConflictId = `conflict_2_${spaceId}`; diff --git a/x-pack/test/spaces_api_integration/common/suites/share_add.ts b/x-pack/test/spaces_api_integration/common/suites/share_add.ts index 219190cb280029..54d636c938b580 100644 --- a/x-pack/test/spaces_api_integration/common/suites/share_add.ts +++ b/x-pack/test/spaces_api_integration/common/suites/share_add.ts @@ -26,7 +26,6 @@ export interface ShareAddTestCase { id: string; namespaces: string[]; failure?: 400 | 403 | 404; - fail400Param?: string; fail403Param?: string; } @@ -42,19 +41,12 @@ export function shareAddTestSuiteFactory(esArchiver: any, supertest: SuperTest async ( response: Record ) => { - const { id, failure, fail400Param, fail403Param } = testCase; + const { id, failure, fail403Param } = testCase; const object = response.body; if (failure === 403) { await expectResponses.forbiddenTypes(fail403Param!)(TYPE)(response); - } else if (failure) { - let error: any; - if (failure === 400) { - error = SavedObjectsErrorHelpers.createBadRequestError( - `${id} already exists in the following namespace(s): ${fail400Param}` - ); - } else if (failure === 404) { - error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); - } + } else if (failure === 404) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); expect(object.error).to.eql(error.output.payload.error); expect(object.statusCode).to.eql(error.output.payload.statusCode); } else { diff --git a/x-pack/test/spaces_api_integration/common/suites/share_remove.ts b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts index 0748aa797264cb..0169d4eb4c64bc 100644 --- a/x-pack/test/spaces_api_integration/common/suites/share_remove.ts +++ b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts @@ -27,7 +27,6 @@ export interface ShareRemoveTestCase { id: string; namespaces: string[]; failure?: 400 | 403 | 404; - fail400Param?: string; } const TYPE = 'sharedtype'; @@ -41,19 +40,12 @@ export function shareRemoveTestSuiteFactory(esArchiver: any, supertest: SuperTes const expectResponseBody = (testCase: ShareRemoveTestCase): ExpectResponseBody => async ( response: Record ) => { - const { id, failure, fail400Param } = testCase; + const { id, failure } = testCase; const object = response.body; if (failure === 403) { await expectForbidden(TYPE)(response); - } else if (failure) { - let error: any; - if (failure === 400) { - error = SavedObjectsErrorHelpers.createBadRequestError( - `${id} doesn't exist in the following namespace(s): ${fail400Param}` - ); - } else if (failure === 404) { - error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); - } + } else if (failure === 404) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); expect(object.error).to.eql(error.output.payload.error); expect(object.statusCode).to.eql(error.output.payload.statusCode); } else { diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts index ddd029c8d7d687..937aaff0595801 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts @@ -12,7 +12,11 @@ import { import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; import { TestInvoker } from '../../common/lib/types'; -import { shareAddTestSuiteFactory, ShareAddTestDefinition } from '../../common/suites/share_add'; +import { + shareAddTestSuiteFactory, + ShareAddTestDefinition, + ShareAddTestCase, +} from '../../common/suites/share_add'; const { DEFAULT: { spaceId: DEFAULT_SPACE_ID }, @@ -33,6 +37,8 @@ const createTestCases = (spaceId: string) => { { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, { ...CASES.ALL_SPACES, namespaces }, { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, + // Test case to check adding all spaces ("*") to a saved object + { ...CASES.EACH_SPACE, namespaces: ['*'] }, // Test cases to check adding multiple namespaces to different saved objects that exist in one space // These are non-exhaustive, they only check cases for adding two additional namespaces to a saved object // More permutations are covered in the corresponding spaces_only test suite @@ -57,13 +63,24 @@ const calculateSingleSpaceAuthZ = ( testCases: ReturnType, spaceId: string ) => { - const targetsOtherSpace = testCases.filter( - (x) => !x.namespaces.includes(spaceId) || x.namespaces.length > 1 - ); - const tmp = testCases.filter((x) => !targetsOtherSpace.includes(x)); // doesn't target other space - const doesntExistInThisSpace = tmp.filter((x) => !x.existingNamespaces.includes(spaceId)); - const existsInThisSpace = tmp.filter((x) => x.existingNamespaces.includes(spaceId)); - return { targetsOtherSpace, doesntExistInThisSpace, existsInThisSpace }; + const targetsAllSpaces: ShareAddTestCase[] = []; + const targetsOtherSpace: ShareAddTestCase[] = []; + const doesntExistInThisSpace: ShareAddTestCase[] = []; + const existsInThisSpace: ShareAddTestCase[] = []; + + for (const testCase of testCases) { + const { namespaces, existingNamespaces } = testCase; + if (namespaces.includes('*')) { + targetsAllSpaces.push(testCase); + } else if (!namespaces.includes(spaceId) || namespaces.length > 1) { + targetsOtherSpace.push(testCase); + } else if (!existingNamespaces.includes(spaceId)) { + doesntExistInThisSpace.push(testCase); + } else { + existsInThisSpace.push(testCase); + } + } + return { targetsAllSpaces, targetsOtherSpace, doesntExistInThisSpace, existsInThisSpace }; }; // eslint-disable-next-line import/no-default-export export default function ({ getService }: TestInvoker) { @@ -79,11 +96,13 @@ export default function ({ getService }: TestInvoker) { return { unauthorized: createTestDefinitions(testCases, true, { fail403Param: 'create' }), authorizedInSpace: [ + createTestDefinitions(thisSpace.targetsAllSpaces, true, { fail403Param: 'create' }), createTestDefinitions(thisSpace.targetsOtherSpace, true, { fail403Param: 'create' }), createTestDefinitions(thisSpace.doesntExistInThisSpace, false), createTestDefinitions(thisSpace.existsInThisSpace, false), ].flat(), authorizedInOtherSpace: [ + createTestDefinitions(thisSpace.targetsAllSpaces, true, { fail403Param: 'create' }), createTestDefinitions(otherSpace.targetsOtherSpace, true, { fail403Param: 'create' }), // If the preflight GET request fails, it will return a 404 error; users who are authorized to create saved objects in the target // space(s) but are not authorized to update saved objects in this space will see a 403 error instead of 404. This is a safeguard to diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts index 4b120a71213b75..34406d3258aa41 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts @@ -35,16 +35,20 @@ const createTestCases = (spaceId: string) => { { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404(spaceId === SPACE_2_ID) }, { id: CASES.DEFAULT_AND_SPACE_2.id, namespaces, ...fail404(spaceId === SPACE_1_ID) }, { id: CASES.SPACE_1_AND_SPACE_2.id, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, - { id: CASES.ALL_SPACES.id, namespaces }, + { id: CASES.EACH_SPACE.id, namespaces }, { id: CASES.DOES_NOT_EXIST.id, namespaces, ...fail404() }, ] as ShareRemoveTestCase[]; - // Test cases to check removing all three namespaces from different saved objects that exist in two spaces - // These are non-exhaustive, they only check some cases -- each object will result in a 404, either because - // it never existed in the target namespace, or it was removed in one of the test cases above - // More permutations are covered in the corresponding spaces_only test suite namespaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; const multipleSpaces = [ + // Test case to check removing all spaces from a saved object that exists in all spaces; + // It fails the second time because the object no longer exists + { ...CASES.ALL_SPACES, namespaces: ['*'] }, + { ...CASES.ALL_SPACES, namespaces: ['*'], ...fail404() }, + // Test cases to check removing all three namespaces from different saved objects that exist in two spaces + // These are non-exhaustive, they only check some cases -- each object will result in a 404, either because + // it never existed in the target namespace, or it was removed in one of the test cases above + // More permutations are covered in the corresponding spaces_only test suite { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404() }, { id: CASES.DEFAULT_AND_SPACE_2.id, namespaces, ...fail404() }, { id: CASES.SPACE_1_AND_SPACE_2.id, namespaces, ...fail404() }, diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts index 25ba986a12fd88..8b8e449b3c323a 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts @@ -33,6 +33,7 @@ const createSingleTestCases = (spaceId: string) => { { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.EACH_SPACE, namespaces }, { ...CASES.ALL_SPACES, namespaces }, { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, ]; @@ -42,14 +43,26 @@ const createSingleTestCases = (spaceId: string) => { * These are non-exhaustive, but they check different permutations of saved objects and spaces to add */ const createMultiTestCases = () => { - const allSpaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; - let id = CASES.DEFAULT_ONLY.id; - const one = [{ id, namespaces: allSpaces }]; - id = CASES.DEFAULT_AND_SPACE_1.id; - const two = [{ id, namespaces: allSpaces }]; - id = CASES.ALL_SPACES.id; - const three = [{ id, namespaces: allSpaces }]; - return { one, two, three }; + const eachSpace = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + const allSpaces = ['*']; + // for each of the cases below, test adding each space and all spaces to the object + const one = [ + { id: CASES.DEFAULT_ONLY.id, namespaces: eachSpace }, + { id: CASES.DEFAULT_ONLY.id, namespaces: allSpaces }, + ]; + const two = [ + { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces: eachSpace }, + { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces: allSpaces }, + ]; + const three = [ + { id: CASES.EACH_SPACE.id, namespaces: eachSpace }, + { id: CASES.EACH_SPACE.id, namespaces: allSpaces }, + ]; + const four = [ + { id: CASES.ALL_SPACES.id, namespaces: eachSpace }, + { id: CASES.ALL_SPACES.id, namespaces: allSpaces }, + ]; + return { one, two, three, four }; }; // eslint-disable-next-line import/no-default-export @@ -68,6 +81,7 @@ export default function ({ getService }: TestInvoker) { one: createTestDefinitions(testCases.one, false), two: createTestDefinitions(testCases.two, false), three: createTestDefinitions(testCases.three, false), + four: createTestDefinitions(testCases.four, false), }; }; @@ -76,9 +90,10 @@ export default function ({ getService }: TestInvoker) { const tests = createSingleTests(spaceId); addTests(`targeting the ${spaceId} space`, { spaceId, tests }); }); - const { one, two, three } = createMultiTests(); + const { one, two, three, four } = createMultiTests(); addTests('for a saved object in the default space', { tests: one }); addTests('for a saved object in the default and space_1 spaces', { tests: two }); addTests('for a saved object in the default, space_1, and space_2 spaces', { tests: three }); + addTests('for a saved object in all spaces', { tests: four }); }); } diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts index 2c4506b7235339..bb0e6f858cbab7 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts @@ -33,7 +33,7 @@ const createSingleTestCases = (spaceId: string) => { { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, - { ...CASES.ALL_SPACES, namespaces }, + { ...CASES.EACH_SPACE, namespaces }, { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, ]; }; @@ -56,7 +56,7 @@ const createMultiTestCases = () => { { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this object's namespaces no longer contains DEFAULT_SPACE_ID { id, namespaces: [SPACE_1_ID], ...fail404() }, // this object's namespaces does contain SPACE_1_ID ]; - id = CASES.ALL_SPACES.id; + id = CASES.EACH_SPACE.id; const three = [ { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, nonExistentSpaceId] }, // this saved object will not be found in the context of the current namespace ('default') @@ -64,7 +64,15 @@ const createMultiTestCases = () => { { id, namespaces: [SPACE_1_ID], ...fail404() }, // this object's namespaces no longer contains SPACE_1_ID { id, namespaces: [SPACE_2_ID], ...fail404() }, // this object's namespaces does contain SPACE_2_ID ]; - return { one, two, three }; + id = CASES.ALL_SPACES.id; + const four = [ + { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, nonExistentSpaceId] }, + // this saved object will still be found in the context of the current namespace ('default') + { id, namespaces: ['*'] }, + // this object no longer exists + { id, namespaces: ['*'], ...fail404() }, + ]; + return { one, two, three, four }; }; // eslint-disable-next-line import/no-default-export @@ -83,6 +91,7 @@ export default function ({ getService }: TestInvoker) { one: createTestDefinitions(testCases.one, false), two: createTestDefinitions(testCases.two, false), three: createTestDefinitions(testCases.three, false), + four: createTestDefinitions(testCases.four, false), }; }; @@ -91,9 +100,10 @@ export default function ({ getService }: TestInvoker) { const tests = createSingleTests(spaceId); addTests(`targeting the ${spaceId} space`, { spaceId, tests }); }); - const { one, two, three } = createMultiTests(); + const { one, two, three, four } = createMultiTests(); addTests('for a saved object in the default space', { tests: one }); addTests('for a saved object in the default and space_1 spaces', { tests: two }); addTests('for a saved object in the default, space_1, and space_2 spaces', { tests: three }); + addTests('for a saved object in all spaces', { tests: four }); }); } From 966f82675ca6b782fa840d0d6281604e3b83ee3b Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 10 Sep 2020 22:39:39 -0400 Subject: [PATCH 03/25] Small refactor for SecureSavedObjectsClientWrapper unit tests Changed how options are passed into tests and how privilege checks are tested so that tests are easier to understand and change. --- ...ecure_saved_objects_client_wrapper.test.ts | 85 +++++++++++++------ 1 file changed, 58 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 491c44f28bed73..1311a27b54b81f 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -120,7 +120,7 @@ const expectSuccess = async (fn: Function, args: Record, action?: s const expectPrivilegeCheck = async ( fn: Function, args: Record, - namespacesOverride?: Array + namespaceOrNamespaces: string | undefined | Array ) => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( getMockCheckPrivilegesFailure @@ -135,7 +135,7 @@ const expectPrivilegeCheck = async ( expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( actions, - namespacesOverride ?? args.options?.namespace ?? args.options?.namespaces + namespaceOrNamespaces ); }; @@ -399,7 +399,7 @@ describe('#bulkCreate', () => { const attributes = { some: 'attr' }; const obj1 = Object.freeze({ type: 'foo', otherThing: 'sup', attributes }); const obj2 = Object.freeze({ type: 'bar', otherThing: 'everyone', attributes }); - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const objects = [obj1]; @@ -408,6 +408,7 @@ describe('#bulkCreate', () => { test(`throws decorated ForbiddenError when unauthorized`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectForbiddenError(client.bulkCreate, { objects, options }); }); @@ -416,17 +417,20 @@ describe('#bulkCreate', () => { clientOpts.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; + const options = { namespace }; const result = await expectSuccess(client.bulkCreate, { objects, options }); expect(result).toEqual(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { const objects = [obj1, obj2]; - await expectPrivilegeCheck(client.bulkCreate, { objects, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.bulkCreate, { objects, options }, namespace); }); test(`filters namespaces that the user doesn't have access to`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectObjectsNamespaceFiltering(client.bulkCreate, { objects, options }); }); }); @@ -434,7 +438,7 @@ describe('#bulkCreate', () => { describe('#bulkGet', () => { const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const objects = [obj1]; @@ -443,6 +447,7 @@ describe('#bulkGet', () => { test(`throws decorated ForbiddenError when unauthorized`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectForbiddenError(client.bulkGet, { objects, options }); }); @@ -451,17 +456,20 @@ describe('#bulkGet', () => { clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; + const options = { namespace }; const result = await expectSuccess(client.bulkGet, { objects, options }); expect(result).toEqual(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { const objects = [obj1, obj2]; - await expectPrivilegeCheck(client.bulkGet, { objects, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.bulkGet, { objects, options }, namespace); }); test(`filters namespaces that the user doesn't have access to`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectObjectsNamespaceFiltering(client.bulkGet, { objects, options }); }); }); @@ -469,7 +477,7 @@ describe('#bulkGet', () => { describe('#bulkUpdate', () => { const obj1 = Object.freeze({ type: 'foo', id: 'foo-id', attributes: { some: 'attr' } }); const obj2 = Object.freeze({ type: 'bar', id: 'bar-id', attributes: { other: 'attr' } }); - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const objects = [obj1]; @@ -478,6 +486,7 @@ describe('#bulkUpdate', () => { test(`throws decorated ForbiddenError when unauthorized`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectForbiddenError(client.bulkUpdate, { objects, options }); }); @@ -486,14 +495,16 @@ describe('#bulkUpdate', () => { clientOpts.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; + const options = { namespace }; const result = await expectSuccess(client.bulkUpdate, { objects, options }); expect(result).toEqual(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { const objects = [obj1, obj2]; - const namespacesOverride = [options.namespace]; // the bulkCreate function checks privileges as an array - await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespacesOverride); + const options = { namespace }; + const namespaces = [options.namespace]; // the bulkUpdate function always checks privileges as an array + await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespaces); }); test(`checks privileges for object namespaces if present`, async () => { @@ -501,13 +512,14 @@ describe('#bulkUpdate', () => { { ...obj1, namespace: 'foo-ns' }, { ...obj2, namespace: 'bar-ns' }, ]; - const namespacesOverride = [undefined, 'foo-ns', 'bar-ns']; - // use the default namespace for the options - await expectPrivilegeCheck(client.bulkUpdate, { objects, options: {} }, namespacesOverride); + const namespaces = [undefined, 'foo-ns', 'bar-ns']; + const options = {}; // use the default namespace for the options + await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespaces); }); test(`filters namespaces that the user doesn't have access to`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectObjectsNamespaceFiltering(client.bulkUpdate, { objects, options }); }); }); @@ -515,7 +527,7 @@ describe('#bulkUpdate', () => { describe('#checkConflicts', () => { const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { const objects = [obj1, obj2]; @@ -524,6 +536,7 @@ describe('#checkConflicts', () => { test(`throws decorated ForbiddenError when unauthorized`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectForbiddenError(client.checkConflicts, { objects, options }, 'checkConflicts'); }); @@ -532,6 +545,7 @@ describe('#checkConflicts', () => { clientOpts.baseClient.checkConflicts.mockResolvedValue(apiCallReturnValue as any); const objects = [obj1, obj2]; + const options = { namespace }; const result = await expectSuccess( client.checkConflicts, { objects, options }, @@ -542,20 +556,22 @@ describe('#checkConflicts', () => { test(`checks privileges for user, actions, and namespace`, async () => { const objects = [obj1, obj2]; - await expectPrivilegeCheck(client.checkConflicts, { objects, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.checkConflicts, { objects, options }, namespace); }); }); describe('#create', () => { const type = 'foo'; const attributes = { some_attr: 's' }; - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { await expectGeneralError(client.create, { type }); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; await expectForbiddenError(client.create, { type, attributes, options }); }); @@ -563,15 +579,18 @@ describe('#create', () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); + const options = { namespace }; const result = await expectSuccess(client.create, { type, attributes, options }); expect(result).toBe(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { - await expectPrivilegeCheck(client.create, { type, attributes, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.create, { type, attributes, options }, namespace); }); test(`filters namespaces that the user doesn't have access to`, async () => { + const options = { namespace }; await expectObjectNamespaceFiltering(client.create, { type, attributes, options }); }); }); @@ -579,13 +598,14 @@ describe('#create', () => { describe('#delete', () => { const type = 'foo'; const id = `${type}-id`; - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.delete, { type, id }); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; await expectForbiddenError(client.delete, { type, id, options }); }); @@ -593,18 +613,21 @@ describe('#delete', () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.delete.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; const result = await expectSuccess(client.delete, { type, id, options }); expect(result).toBe(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { - await expectPrivilegeCheck(client.delete, { type, id, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.delete, { type, id, options }, namespace); }); }); describe('#find', () => { const type1 = 'foo'; const type2 = 'bar'; + const namespaces = ['some-ns']; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.find, { type: type1 }); @@ -635,7 +658,7 @@ describe('#find', () => { const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); - const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); + const options = { type: type1, namespaces }; const result = await expectSuccess(client.find, { options }); expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({ ...options, @@ -700,19 +723,19 @@ describe('#find', () => { getMockCheckPrivilegesSuccess ); - const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); + const options = { type: [type1, type2], namespaces }; await expect(client.find(options)).rejects.toThrowErrorMatchingInlineSnapshot( `"_find across namespaces is not permitted when the Spaces plugin is disabled."` ); }); test(`checks privileges for user, actions, and namespaces`, async () => { - const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); - await expectPrivilegeCheck(client.find, { options }); + const options = { type: [type1, type2], namespaces }; + await expectPrivilegeCheck(client.find, { options }, namespaces); }); test(`filters namespaces that the user doesn't have access to`, async () => { - const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); + const options = { type: [type1, type2], namespaces }; await expectObjectsNamespaceFiltering(client.find, { options }); }); }); @@ -720,13 +743,14 @@ describe('#find', () => { describe('#get', () => { const type = 'foo'; const id = `${type}-id`; - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.get, { type, id }); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; await expectForbiddenError(client.get, { type, id, options }); }); @@ -734,15 +758,18 @@ describe('#get', () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.get.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; const result = await expectSuccess(client.get, { type, id, options }); expect(result).toBe(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { - await expectPrivilegeCheck(client.get, { type, id, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.get, { type, id, options }, namespace); }); test(`filters namespaces that the user doesn't have access to`, async () => { + const options = { namespace }; await expectObjectNamespaceFiltering(client.get, { type, id, options }); }); }); @@ -822,13 +849,14 @@ describe('#update', () => { const type = 'foo'; const id = `${type}-id`; const attributes = { some: 'attr' }; - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.update, { type, id, attributes }); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; await expectForbiddenError(client.update, { type, id, attributes, options }); }); @@ -836,15 +864,18 @@ describe('#update', () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.update.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; const result = await expectSuccess(client.update, { type, id, attributes, options }); expect(result).toBe(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { - await expectPrivilegeCheck(client.update, { type, id, attributes, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.update, { type, id, attributes, options }, namespace); }); test(`filters namespaces that the user doesn't have access to`, async () => { + const options = { namespace }; await expectObjectNamespaceFiltering(client.update, { type, id, attributes, options }); }); }); From 69f649192266d2dd47d13707ad7aebbe3fec539b Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 18 Sep 2020 16:37:06 -0400 Subject: [PATCH 04/25] Change `create` and `bulkCreate` to allow initial namespaces --- docs/api/saved-objects/bulk_create.asciidoc | 4 + docs/api/saved-objects/create.asciidoc | 4 + ...jectsbulkcreateobject.initialnamespaces.md | 15 +++ ...ore-server.savedobjectsbulkcreateobject.md | 1 + ...dobjectscreateoptions.initialnamespaces.md | 15 +++ ...n-core-server.savedobjectscreateoptions.md | 1 + .../saved_objects/routes/bulk_create.ts | 1 + .../server/saved_objects/routes/create.ts | 5 +- .../service/lib/repository.test.js | 111 ++++++++++++++++-- .../saved_objects/service/lib/repository.ts | 47 ++++++-- .../service/saved_objects_client.ts | 14 +++ src/core/server/server.api.md | 2 + ...ecure_saved_objects_client_wrapper.test.ts | 26 +++- .../secure_saved_objects_client_wrapper.ts | 17 ++- .../common/suites/bulk_create.ts | 31 ++++- .../common/suites/create.ts | 40 ++++++- .../security_and_spaces/apis/bulk_create.ts | 49 +++++--- .../security_and_spaces/apis/create.ts | 29 +++-- .../security_only/apis/bulk_create.ts | 2 + .../security_only/apis/create.ts | 2 + .../spaces_only/apis/bulk_create.ts | 2 + .../spaces_only/apis/create.ts | 2 + 22 files changed, 365 insertions(+), 55 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md diff --git a/docs/api/saved-objects/bulk_create.asciidoc b/docs/api/saved-objects/bulk_create.asciidoc index 4f572b49ee5ff6..e77559f5d86448 100644 --- a/docs/api/saved-objects/bulk_create.asciidoc +++ b/docs/api/saved-objects/bulk_create.asciidoc @@ -41,6 +41,10 @@ experimental[] Create multiple {kib} saved objects. `references`:: (Optional, array) Objects with `name`, `id`, and `type` properties that describe the other saved objects in the referenced object. To refer to the other saved object, use `name` in the attributes. Never use `id` to refer to the other saved object. `id` can be automatically updated during migrations, import, or export. +`initialNamespaces`:: + (Optional, string array) Identifiers for the <> in which this object should be created. If this is not provided, the + object will be created in the current space. + `version`:: (Optional, number) Specifies the version. diff --git a/docs/api/saved-objects/create.asciidoc b/docs/api/saved-objects/create.asciidoc index e6f3301bfea2b6..fac4f2bf109fab 100644 --- a/docs/api/saved-objects/create.asciidoc +++ b/docs/api/saved-objects/create.asciidoc @@ -46,6 +46,10 @@ any data that you send to the API is properly formed. `references`:: (Optional, array) Objects with `name`, `id`, and `type` properties that describe the other saved objects that this object references. Use `name` in attributes to refer to the other saved object, but never the `id`, which can update automatically during migrations or import/export. +`initialNamespaces`:: + (Optional, string array) Identifiers for the <> in which this object should be created. If this is not provided, the + object will be created in the current space. + [[saved-objects-api-create-request-codes]] ==== Response code diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md new file mode 100644 index 00000000000000..3db8bbadfbd6bf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) > [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) + +## SavedObjectsBulkCreateObject.initialNamespaces property + +Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). + +Note: this can only be used for multi-namespace object types. + +Signature: + +```typescript +initialNamespaces?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 019d30570ab36b..5ac5f6d9807bd3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -17,6 +17,7 @@ export interface SavedObjectsBulkCreateObject | --- | --- | --- | | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | | [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | string | | +| [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | | [migrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [originId](./kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [references](./kibana-plugin-core-server.savedobjectsbulkcreateobject.references.md) | SavedObjectReference[] | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md new file mode 100644 index 00000000000000..262b0997cb9050 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) > [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) + +## SavedObjectsCreateOptions.initialNamespaces property + +Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). + +Note: this can only be used for multi-namespace object types. + +Signature: + +```typescript +initialNamespaces?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index d936829443753f..e6d306784f8ae3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -16,6 +16,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | Property | Type | Description | | --- | --- | --- | | [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | string | (not recommended) Specify an id for the document | +| [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | | [migrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | | [originId](./kibana-plugin-core-server.savedobjectscreateoptions.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [overwrite](./kibana-plugin-core-server.savedobjectscreateoptions.overwrite.md) | boolean | Overwrite existing documents (defaults to false) | diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index af1a7bd2af9b76..b048c5d8f99bfc 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -44,6 +44,7 @@ export const registerBulkCreateRoute = (router: IRouter) => { }) ) ), + initialNamespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), }) ), }, diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index 6cf906a3b2895d..816315705a375a 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -44,15 +44,16 @@ export const registerCreateRoute = (router: IRouter) => { }) ) ), + initialNamespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { type, id } = req.params; const { overwrite } = req.query; - const { attributes, migrationVersion, references } = req.body; + const { attributes, migrationVersion, references, initialNamespaces } = req.body; - const options = { id, overwrite, migrationVersion, references }; + const options = { id, overwrite, migrationVersion, references, initialNamespaces }; const result = await context.core.savedObjects.client.create(type, attributes, options); return res.ok({ body: result }); }) diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 0b4b81d8e89bf9..9e06994ecfb7d6 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -635,6 +635,32 @@ describe('SavedObjectsRepository', () => { await test(namespace); }); + it(`adds initialNamespaces instead of namespaces`, async () => { + const test = async (namespace) => { + const ns2 = 'bar-namespace'; + const ns3 = 'baz-namespace'; + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2] }, + { ...obj2, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns3] }, + ]; + await bulkCreateSuccess(objects, { namespace, overwrite: true }); + const body = [ + expect.any(Object), + expect.objectContaining({ namespaces: [ns2] }), + expect.any(Object), + expect.objectContaining({ namespaces: [ns3] }), + ]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + client.mget.mockClear(); + }; + await test(undefined); + await test(namespace); + }); + it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { const test = async (namespace) => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; @@ -732,6 +758,34 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); + it(`returns error when initialNamespaces is used with a non-multi-namespace object`, async () => { + const test = async (objType) => { + const obj = { ...obj3, type: objType, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestError('"initialNamespaces" can only be used on multi-namespace types') + ) + ); + }; + await test('dashboard'); + await test(NAMESPACE_AGNOSTIC_TYPE); + }); + + it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestError('"initialNamespaces" must be a non-empty array of strings') + ) + ); + }); + it(`returns error when type is invalid`, async () => { const obj = { ...obj3, type: 'unknownType' }; await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); @@ -1895,21 +1949,23 @@ describe('SavedObjectsRepository', () => { ); }); - it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + it(`prepends namespace to the id and adds namespace to the body when providing namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id, namespace }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ id: `${namespace}:${type}:${id}`, + body: expect.objectContaining({ namespace }), }), expect.anything() ); }); - it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + it(`doesn't prepend namespace to the id or add namespace to the body when providing no namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ id: `${type}:${id}`, + body: expect.not.objectContaining({ namespace: expect.anything() }), }), expect.anything() ); @@ -1920,25 +1976,44 @@ describe('SavedObjectsRepository', () => { expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ id: `${type}:${id}`, + body: expect.not.objectContaining({ namespace: expect.anything() }), }), expect.anything() ); }); - it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { - await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); + it(`doesn't prepend namespace to the id and adds namespaces to body when using multi-namespace type`, async () => { + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ - id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_TYPE}:${id}`, + body: expect.objectContaining({ namespaces: [namespace] }), }), expect.anything() ); - client.create.mockClear(); + }); - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + it(`adds initialNamespaces instead of namespaces`, async () => { + const options = { id, namespace, initialNamespaces: ['bar-namespace', 'baz-namespace'] }; + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, options); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}`, + body: expect.objectContaining({ namespaces: options.initialNamespaces }), + }), + expect.anything() + ); + }); + + it(`doesn't prepend namespace to the id or add namespace or namespaces fields when using namespace-agnostic type`, async () => { + await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, + body: expect.not.objectContaining({ + namespace: expect.anything(), + namespaces: expect.anything(), + }), }), expect.anything() ); @@ -1946,6 +2021,28 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { + it(`throws when options.initialNamespaces is used with a non-multi-namespace object`, async () => { + const test = async (objType) => { + await expect( + savedObjectsRepository.create(objType, attributes, { initialNamespaces: [namespace] }) + ).rejects.toThrowError( + createBadRequestError( + '"options.initialNamespaces" can only be used on multi-namespace types' + ) + ); + }; + await test('dashboard'); + await test(NAMESPACE_AGNOSTIC_TYPE); + }); + + it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + await expect( + savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) + ).rejects.toThrowError( + createBadRequestError('"options.initialNamespaces" must be a non-empty array of strings') + ); + }); + it(`throws when options.namespace is '*'`, async () => { await expect( savedObjectsRepository.create(type, attributes, { namespace: ALL_NAMESPACES_STRING }) diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 1feedd6c3bd787..39aacd6b05b7b1 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -230,10 +230,23 @@ export class SavedObjectsRepository { references = [], refresh = DEFAULT_REFRESH_SETTING, originId, + initialNamespaces, version, } = options; const namespace = normalizeNamespace(options.namespace); + if (initialNamespaces) { + if (!this._registry.isMultiNamespace(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"options.initialNamespaces" can only be used on multi-namespace types' + ); + } else if (!initialNamespaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"options.initialNamespaces" must be a non-empty array of strings' + ); + } + } + if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } @@ -247,9 +260,11 @@ export class SavedObjectsRepository { } else if (this._registry.isMultiNamespace(type)) { if (id && overwrite) { // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces - savedObjectNamespaces = await this.preflightGetNamespaces(type, id, namespace); + // note: this check throws an error if the object is found but does not exist in this namespace + const existingNamespaces = await this.preflightGetNamespaces(type, id, namespace); + savedObjectNamespaces = initialNamespaces || existingNamespaces; } else { - savedObjectNamespaces = getSavedObjectNamespaces(namespace); + savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); } } @@ -305,14 +320,25 @@ export class SavedObjectsRepository { let bulkGetRequestIndexCounter = 0; const expectedResults: Either[] = objects.map((object) => { + let error: DecoratedError | undefined; if (!this._allowedTypes.includes(object.type)) { + error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); + } else if (object.initialNamespaces) { + if (!this._registry.isMultiNamespace(object.type)) { + error = SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" can only be used on multi-namespace types' + ); + } else if (!object.initialNamespaces.length) { + error = SavedObjectsErrorHelpers.createBadRequestError( + '"initialNamespaces" must be a non-empty array of strings' + ); + } + } + + if (error) { return { tag: 'Left' as 'Left', - error: { - id: object.id, - type: object.type, - error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type)), - }, + error: { id: object.id, type: object.type, error: errorContent(error) }, }; } @@ -362,7 +388,7 @@ export class SavedObjectsRepository { let versionProperties; const { esRequestIndex, - object: { version, ...object }, + object: { initialNamespaces, version, ...object }, method, } = expectedBulkGetResult.value; if (esRequestIndex !== undefined) { @@ -383,13 +409,14 @@ export class SavedObjectsRepository { }, }; } - savedObjectNamespaces = getSavedObjectNamespaces(namespace, docFound && actualResult); + savedObjectNamespaces = + initialNamespaces || getSavedObjectNamespaces(namespace, docFound && actualResult); versionProperties = getExpectedVersionProperties(version, actualResult); } else { if (this._registry.isSingleNamespace(object.type)) { savedObjectNamespace = namespace; } else if (this._registry.isMultiNamespace(object.type)) { - savedObjectNamespaces = getSavedObjectNamespaces(namespace); + savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); } versionProperties = getExpectedVersionProperties(version); } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index edfbbc4c6b4083..6782998d1bf1ea 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -50,6 +50,13 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { refresh?: MutatingOperationRefreshSetting; /** Optional ID of the original saved object, if this object's `id` was regenerated */ originId?: string; + /** + * Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in + * {@link SavedObjectsCreateOptions}. + * + * Note: this can only be used for multi-namespace object types. + */ + initialNamespaces?: string[]; } /** @@ -66,6 +73,13 @@ export interface SavedObjectsBulkCreateObject { migrationVersion?: SavedObjectsMigrationVersion; /** Optional ID of the original saved object, if this object's `id` was regenerated */ originId?: string; + /** + * Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in + * {@link SavedObjectsCreateOptions}. + * + * Note: this can only be used for multi-namespace object types. + */ + initialNamespaces?: string[]; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index cb7501dd5d47fb..c8d6c296ca0642 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1819,6 +1819,7 @@ export interface SavedObjectsBulkCreateObject { attributes: T; // (undocumented) id?: string; + initialNamespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; originId?: string; // (undocumented) @@ -1976,6 +1977,7 @@ export interface SavedObjectsCoreFieldMapping { // @public (undocumented) export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { id?: string; + initialNamespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; originId?: string; overwrite?: boolean; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 1311a27b54b81f..d58413ec5c2717 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -425,7 +425,20 @@ describe('#bulkCreate', () => { test(`checks privileges for user, actions, and namespace`, async () => { const objects = [obj1, obj2]; const options = { namespace }; - await expectPrivilegeCheck(client.bulkCreate, { objects, options }, namespace); + await expectPrivilegeCheck(client.bulkCreate, { objects, options }, [namespace]); + }); + + test(`checks privileges for user, actions, namespace, and initialNamespaces`, async () => { + const objects = [ + { ...obj1, initialNamespaces: 'another-ns' }, + { ...obj2, initialNamespaces: 'yet-another-ns' }, + ]; + const options = { namespace }; + await expectPrivilegeCheck(client.bulkCreate, { objects, options }, [ + namespace, + 'another-ns', + 'yet-another-ns', + ]); }); test(`filters namespaces that the user doesn't have access to`, async () => { @@ -586,7 +599,16 @@ describe('#create', () => { test(`checks privileges for user, actions, and namespace`, async () => { const options = { namespace }; - await expectPrivilegeCheck(client.create, { type, attributes, options }, namespace); + await expectPrivilegeCheck(client.create, { type, attributes, options }, [namespace]); + }); + + test(`checks privileges for user, actions, namespace, and initialNamespaces`, async () => { + const options = { namespace, initialNamespaces: ['another-ns', 'yet-another-ns'] }; + await expectPrivilegeCheck(client.create, { type, attributes, options }, [ + namespace, + 'another-ns', + 'yet-another-ns', + ]); }); test(`filters namespaces that the user doesn't have access to`, async () => { 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 a71fd856a611f2..95da13a7228d69 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 @@ -87,7 +87,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsCreateOptions = {} ) { const args = { type, attributes, options }; - await this.ensureAuthorized(type, 'create', options.namespace, { args }); + const namespaces = [options.namespace, ...(options.initialNamespaces || [])]; + await this.ensureAuthorized(type, 'create', namespaces, { args }); const savedObject = await this.baseClient.create(type, attributes, options); return await this.redactSavedObjectNamespaces(savedObject); @@ -113,13 +114,17 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsBaseOptions = {} ) { const args = { objects, options }; - await this.ensureAuthorized( - this.getUniqueObjectTypes(objects), - 'bulk_create', - options.namespace, - { args } + const namespaces = objects.reduce( + (acc, { initialNamespaces = [] }) => { + return acc.concat(initialNamespaces); + }, + [options.namespace] ); + await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_create', namespaces, { + args, + }); + const response = await this.baseClient.bulkCreate(objects, options); return await this.redactSavedObjectsNamespaces(response); } diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index b1608946b8e62f..6abda8f51ed5a8 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -8,9 +8,8 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; -import { SPACES } from '../lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../lib/spaces'; import { - createRequest, expectResponses, getUrlPrefix, getTestTitle, @@ -18,29 +17,57 @@ import { } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types'; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + export interface BulkCreateTestDefinition extends TestDefinition { request: Array<{ type: string; id: string }>; overwrite: boolean; } export type BulkCreateTestSuite = TestSuite; export interface BulkCreateTestCase extends TestCase { + initialNamespaces?: string[]; failure?: 400 | 409; // only used for permitted response case fail409Param?: string; } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; +const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_EACH_SPACE_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'new-each-space-id', + expectedNamespaces: EACH_SPACE, // expected namespaces of resulting object + initialNamespaces: EACH_SPACE, // args passed to the bulkCreate method +}); +const NEW_ALL_SPACES_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'new-all-spaces-id', + expectedNamespaces: [ALL_SPACES_ID], // expected namespaces of resulting object + initialNamespaces: [ALL_SPACES_ID], // args passed to the bulkCreate method +}); const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); export const TEST_CASES: Record = Object.freeze({ ...CASES, NEW_SINGLE_NAMESPACE_OBJ, NEW_MULTI_NAMESPACE_OBJ, + NEW_EACH_SPACE_OBJ, + NEW_ALL_SPACES_OBJ, NEW_NAMESPACE_AGNOSTIC_OBJ, }); +const createRequest = ({ type, id, initialNamespaces }: BulkCreateTestCase) => ({ + type, + id, + ...(initialNamespaces && { initialNamespaces }), +}); + export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('bulk_create'); const expectResponseBody = ( diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts index 7e28d5ed9ed94f..fb7f3c5c61618d 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -7,9 +7,8 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; -import { SPACES } from '../lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../lib/spaces'; import { - createRequest, expectResponses, getUrlPrefix, getTestTitle, @@ -17,30 +16,58 @@ import { } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types'; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + export interface CreateTestDefinition extends TestDefinition { - request: { type: string; id: string }; + request: { type: string; id: string; initialNamespaces?: string[] }; overwrite: boolean; } export type CreateTestSuite = TestSuite; export interface CreateTestCase extends TestCase { + initialNamespaces?: string[]; failure?: 400 | 403 | 409; } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; +const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; // ID intentionally left blank on NEW_SINGLE_NAMESPACE_OBJ to ensure we can create saved objects without specifying the ID // we could create six separate test cases to test every permutation, but there's no real value in doing so const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: '' }); const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_EACH_SPACE_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'new-each-space-id', + expectedNamespaces: EACH_SPACE, // expected namespaces of resulting object + initialNamespaces: EACH_SPACE, // args passed to the bulkCreate method +}); +const NEW_ALL_SPACES_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'new-all-spaces-id', + expectedNamespaces: [ALL_SPACES_ID], // expected namespaces of resulting object + initialNamespaces: [ALL_SPACES_ID], // args passed to the bulkCreate method +}); const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); export const TEST_CASES: Record = Object.freeze({ ...CASES, NEW_SINGLE_NAMESPACE_OBJ, NEW_MULTI_NAMESPACE_OBJ, + NEW_EACH_SPACE_OBJ, + NEW_ALL_SPACES_OBJ, NEW_NAMESPACE_AGNOSTIC_OBJ, }); +const createRequest = ({ type, id, initialNamespaces }: CreateTestCase) => ({ + type, + id, + initialNamespaces, +}); + export function createTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('create'); const expectResponseBody = ( @@ -96,9 +123,12 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe for (const test of tests) { it(`should return ${test.responseStatusCode} ${test.title}`, async () => { - const { type, id } = test.request; + const { type, id, initialNamespaces } = test.request; const path = `${type}${id ? `/${id}` : ''}`; - const requestBody = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; + const requestBody = { + attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL }, + ...(initialNamespaces && { initialNamespaces }), + }; const query = test.overwrite ? '?overwrite=true' : ''; await supertest .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${path}${query}`) diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index e0faae9a074b09..a34bb4b3e78e7b 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -64,9 +64,10 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; + const crossNamespace = [CASES.NEW_EACH_SPACE_OBJ, CASES.NEW_ALL_SPACES_OBJ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); - return { normalTypes, hiddenType, allTypes }; + return { normalTypes, crossNamespace, hiddenType, allTypes }; }; export default function ({ getService }: FtrProviderContext) { @@ -80,22 +81,37 @@ export default function ({ getService }: FtrProviderContext) { supertest ); const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => { - const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId); + const { normalTypes, crossNamespace, hiddenType, allTypes } = createTestCases( + overwrite, + spaceId + ); // use singleRequest to reduce execution time and/or test combined cases + const authorizedCommon = [ + createTestDefinitions(normalTypes, false, overwrite, { + spaceId, + user, + singleRequest: true, + }), + createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), + createTestDefinitions(allTypes, true, overwrite, { + spaceId, + user, + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(); return { unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId, user }), - authorized: [ - createTestDefinitions(normalTypes, false, overwrite, { - spaceId, - user, - singleRequest: true, - }), - createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), - createTestDefinitions(allTypes, true, overwrite, { + authorizedAtSpace: [ + authorizedCommon, + createTestDefinitions(crossNamespace, true, overwrite, { spaceId, user }), + ].flat(), + authorizedEverywhere: [ + authorizedCommon, + createTestDefinitions(crossNamespace, false, overwrite, { spaceId, user, singleRequest: true, - responseBodyOverride: expectForbidden(['hiddentype']), }), ].flat(), superuser: createTestDefinitions(allTypes, false, overwrite, { @@ -125,10 +141,15 @@ export default function ({ getService }: FtrProviderContext) { const { unauthorized } = createTests(overwrite!, spaceId, user); _addTests(user, unauthorized); }); - [users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => { - const { authorized } = createTests(overwrite!, spaceId, user); - _addTests(user, authorized); + + const { authorizedAtSpace } = createTests(overwrite!, spaceId, users.allAtSpace); + _addTests(users.allAtSpace, authorizedAtSpace); + + [users.dualAll, users.allGlobally].forEach((user) => { + const { authorizedEverywhere } = createTests(overwrite!, spaceId, user); + _addTests(user, authorizedEverywhere); }); + const { superuser } = createTests(overwrite!, spaceId, users.superuser); _addTests(users.superuser, superuser); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index 7bc3e027bfadee..37328c0ffc3424 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -53,9 +53,10 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; + const crossNamespace = [CASES.NEW_EACH_SPACE_OBJ, CASES.NEW_ALL_SPACES_OBJ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; - const allTypes = normalTypes.concat(hiddenType); - return { normalTypes, hiddenType, allTypes }; + const allTypes = normalTypes.concat(crossNamespace, hiddenType); + return { normalTypes, crossNamespace, hiddenType, allTypes }; }; export default function ({ getService }: FtrProviderContext) { @@ -65,11 +66,20 @@ export default function ({ getService }: FtrProviderContext) { const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest); const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => { - const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId); + const { normalTypes, crossNamespace, hiddenType, allTypes } = createTestCases( + overwrite, + spaceId + ); return { unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId, user }), - authorized: [ + authorizedAtSpace: [ + createTestDefinitions(normalTypes, false, overwrite, { spaceId, user }), + createTestDefinitions(crossNamespace, true, overwrite, { spaceId, user }), + createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), + ].flat(), + authorizedEverywhere: [ createTestDefinitions(normalTypes, false, overwrite, { spaceId, user }), + createTestDefinitions(crossNamespace, false, overwrite, { spaceId, user }), createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), ].flat(), superuser: createTestDefinitions(allTypes, false, overwrite, { spaceId, user }), @@ -95,10 +105,15 @@ export default function ({ getService }: FtrProviderContext) { const { unauthorized } = createTests(overwrite!, spaceId, user); _addTests(user, unauthorized); }); - [users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => { - const { authorized } = createTests(overwrite!, spaceId, user); - _addTests(user, authorized); + + const { authorizedAtSpace } = createTests(overwrite!, spaceId, users.allAtSpace); + _addTests(users.allAtSpace, authorizedAtSpace); + + [users.dualAll, users.allGlobally].forEach((user) => { + const { authorizedEverywhere } = createTests(overwrite!, spaceId, user); + _addTests(user, authorizedEverywhere); }); + const { superuser } = createTests(overwrite!, spaceId, users.superuser); _addTests(users.superuser, superuser); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index 71743b6267e6d6..dc340f2c2aa7cb 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -36,6 +36,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + CASES.NEW_EACH_SPACE_OBJ, + CASES.NEW_ALL_SPACES_OBJ, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index e0847ac5fd08a3..f469ffc97fbe08 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -35,6 +35,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + CASES.NEW_EACH_SPACE_OBJ, + CASES.NEW_ALL_SPACES_OBJ, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index 7d9fcc8e46434a..d06109587c3b31 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -60,6 +60,8 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + CASES.NEW_EACH_SPACE_OBJ, + CASES.NEW_ALL_SPACES_OBJ, ]; }; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index 2baf0414117e44..c5013a0f0ddd3e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -48,6 +48,8 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + CASES.NEW_EACH_SPACE_OBJ, + CASES.NEW_ALL_SPACES_OBJ, ]; }; From 2e1dabb85d326d99a5a2ec3e158f7c5964fccd1a Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 21 Sep 2020 13:23:44 -0400 Subject: [PATCH 05/25] Change "share to space" initial warning callout Removed "Make a copy" button, made "make a copy" inline text a clickable link instead. --- .../components/share_to_space_flyout.test.tsx | 2 +- .../components/share_to_space_form.tsx | 26 +++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx index c17a2dcb1a831d..27656cd912695e 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx @@ -178,7 +178,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); - const copyButton = findTestSubject(wrapper, 'sts-copy-button'); // this button is only present in the warning callout + const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout await act(async () => { copyButton.simulate('click'); 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 ad84ea85d5e54a..717f77e562c42e 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 @@ -6,7 +6,7 @@ import './share_to_space_form.scss'; import React, { Fragment } from 'react'; -import { EuiHorizontalRule, EuiFormRow, EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { EuiHorizontalRule, EuiFormRow, EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { ShareOptions, SpaceTarget } from '../types'; import { SelectableSpacesControl } from './selectable_spaces_control'; @@ -42,20 +42,18 @@ export const ShareToSpaceForm = (props: Props) => { > props.makeCopy()}> + + + ), + }} /> - - props.makeCopy()} - color="warning" - data-test-subj="sts-copy-button" - size="s" - > - - From 6012e054a1fa438bf3da0be696c7b81efabf245a Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 22 Sep 2020 14:31:57 -0400 Subject: [PATCH 06/25] Clean up "Shared spaces" column code, add unit tests No functionality changes, just some refactoring. --- ...are_saved_objects_to_space_column.test.tsx | 181 ++++++++++++++++++ .../share_saved_objects_to_space_column.tsx | 20 +- .../share_saved_objects_to_space/types.ts | 2 +- 3 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx new file mode 100644 index 00000000000000..40bdb41cd7aec0 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { SpacesManager } from '../spaces_manager'; +import { spacesManagerMock } from '../spaces_manager/mocks'; +import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; +import { SpaceTarget } from './types'; + +const ACTIVE_SPACE: SpaceTarget = { + id: 'default', + name: 'Default', + color: '#ffffff', + isActiveSpace: true, +}; +const getSpaceData = (inactiveSpaceCount: number = 0) => { + const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] + .map((name) => ({ + id: name.toLowerCase(), + name, + color: `#123456`, // must be a valid color as `render()` is used below + isActiveSpace: false, + })) + .slice(0, inactiveSpaceCount); + const spaceTargets = [ACTIVE_SPACE, ...inactive]; + const namespaces = spaceTargets.map(({ id }) => id); + return { spaceTargets, namespaces }; +}; + +describe('ShareToSpaceSavedObjectsManagementColumn', () => { + let spacesManager: SpacesManager; + beforeEach(() => { + spacesManager = spacesManagerMock.create(); + }); + + const createColumn = (spaceTargets: SpaceTarget[], namespaces: string[]) => { + const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); + column.data = spaceTargets.reduce( + (acc, cur) => acc.set(cur.id, cur), + new Map() + ); + const element = column.euiColumn.render(namespaces); + return shallowWithIntl(element); + }; + + /** + * This node displays up to five named spaces (and an indicator for any number of unauthorized spaces) by default. The active space is + * omitted from this list. If more than five named spaces would be displayed, the extras (along with the unauthorized spaces indicator, if + * present) are hidden behind a button. + */ + describe('#euiColumn.render', () => { + describe('with only the active space', () => { + const { spaceTargets, namespaces } = getSpaceData(); + const wrapper = createColumn(spaceTargets, namespaces); + + it('does not show badges or button', async () => { + const badges = wrapper.find('EuiBadge'); + expect(badges).toHaveLength(0); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with the active space and one inactive space', () => { + const { spaceTargets, namespaces } = getSpaceData(1); + const wrapper = createColumn(spaceTargets, namespaces); + + it('shows one badge without button', async () => { + const badges = wrapper.find('EuiBadge'); + expect(badges).toMatchInlineSnapshot(` + + Alpha + + `); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with the active space and five inactive spaces', () => { + const { spaceTargets, namespaces } = getSpaceData(5); + const wrapper = createColumn(spaceTargets, namespaces); + + it('shows badges without button', async () => { + const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and one unauthorized space', () => { + const { spaceTargets, namespaces } = getSpaceData(5); + const wrapper = createColumn(spaceTargets, [...namespaces, '?']); + + it('shows badges without button', async () => { + const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+1']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { + const { spaceTargets, namespaces } = getSpaceData(5); + const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); + + it('shows badges without button', async () => { + const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+2']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with the active space and six inactive spaces', () => { + const { spaceTargets, namespaces } = getSpaceData(6); + const wrapper = createColumn(spaceTargets, namespaces); + + it('shows badges with button', async () => { + let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button.find('FormattedMessage').props()).toEqual({ + defaultMessage: '+{count} more', + id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', + values: { count: 1 }, + }); + + button.simulate('click'); + badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot']); + }); + }); + + describe('with the active space, six inactive spaces, and one unauthorized space', () => { + const { spaceTargets, namespaces } = getSpaceData(6); + const wrapper = createColumn(spaceTargets, [...namespaces, '?']); + + it('shows badges with button', async () => { + let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button.find('FormattedMessage').props()).toEqual({ + defaultMessage: '+{count} more', + id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', + values: { count: 2 }, + }); + + button.simulate('click'); + badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+1']); + }); + }); + + describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { + const { spaceTargets, namespaces } = getSpaceData(6); + const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); + + it('shows badges with button', async () => { + let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button.find('FormattedMessage').props()).toEqual({ + defaultMessage: '+{count} more', + id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', + values: { count: 3 }, + }); + + button.simulate('click'); + badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+2']); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx index 93d7bb0170519f..7ce2ca109941f9 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx @@ -10,15 +10,13 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EuiToolTip } from '@elastic/eui'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - SavedObjectsManagementColumn, - SavedObjectsManagementRecord, -} from '../../../../../src/plugins/saved_objects_management/public'; +import { SavedObjectsManagementColumn } from '../../../../../src/plugins/saved_objects_management/public'; import { SpaceTarget } from './types'; import { SpacesManager } from '../spaces_manager'; import { getSpaceColor } from '..'; const SPACES_DISPLAY_COUNT = 5; +const UNKNOWN_SPACE = '?'; type SpaceMap = Map; interface ColumnDataProps { @@ -33,23 +31,19 @@ const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { return null; } - const authorized = namespaces?.filter((namespace) => namespace !== '?') ?? []; + const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; const authorizedSpaceTargets: SpaceTarget[] = []; authorized.forEach((namespace) => { const spaceTarget = data.get(namespace); if (spaceTarget === undefined) { // in the event that a new space was created after this page has loaded, fall back to displaying the space ID - authorizedSpaceTargets.push({ - id: namespace, - name: namespace, - disabledFeatures: [], - isActiveSpace: false, - }); + authorizedSpaceTargets.push({ id: namespace, name: namespace, isActiveSpace: false }); } else if (!spaceTarget.isActiveSpace) { authorizedSpaceTargets.push(spaceTarget); } }); - const unauthorizedCount = (namespaces?.filter((namespace) => namespace === '?') ?? []).length; + const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) + .length; const unauthorizedTooltip = i18n.translate( 'xpack.spaces.management.shareToSpace.columnUnauthorizedLabel', { defaultMessage: `You don't have permission to view these spaces.` } @@ -117,7 +111,7 @@ export class ShareToSpaceSavedObjectsManagementColumn description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { defaultMessage: 'The other spaces that this object is currently shared to', }), - render: (namespaces: string[] | undefined, _object: SavedObjectsManagementRecord) => ( + render: (namespaces: string[] | undefined) => ( ), }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts index fe41f4a5fadc8b..8b62166baaaa64 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts @@ -17,6 +17,6 @@ export interface ShareSavedObjectsToSpaceResponse { [spaceId: string]: SavedObjectsImportResponse; } -export interface SpaceTarget extends Space { +export interface SpaceTarget extends Omit { isActiveSpace: boolean; } From 87ed4e8e9780dd5dfe2a5bd71f4a3d96c30264a0 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 22 Sep 2020 15:30:28 -0400 Subject: [PATCH 07/25] Change "Shared spaces" column to display "All spaces" badge --- ...are_saved_objects_to_space_column.test.tsx | 25 +++++ .../share_saved_objects_to_space_column.tsx | 104 ++++++++++-------- 2 files changed, 86 insertions(+), 43 deletions(-) diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx index 40bdb41cd7aec0..041728a3eac0da 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx @@ -50,6 +50,7 @@ describe('ShareToSpaceSavedObjectsManagementColumn', () => { * This node displays up to five named spaces (and an indicator for any number of unauthorized spaces) by default. The active space is * omitted from this list. If more than five named spaces would be displayed, the extras (along with the unauthorized spaces indicator, if * present) are hidden behind a button. + * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. */ describe('#euiColumn.render', () => { describe('with only the active space', () => { @@ -177,5 +178,29 @@ describe('ShareToSpaceSavedObjectsManagementColumn', () => { expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+2']); }); }); + + describe('with only "all spaces"', () => { + const wrapper = createColumn([], ['*']); + + it('shows one badge without button', async () => { + const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['* All spaces']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { + // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else + const { spaceTargets, namespaces } = getSpaceData(6); + const wrapper = createColumn(spaceTargets, ['*', ...namespaces, '?']); + + it('shows one badge without button', async () => { + const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['* All spaces']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); }); }); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx index 7ce2ca109941f9..b34287a3c5c449 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx @@ -16,6 +16,7 @@ import { SpacesManager } from '../spaces_manager'; import { getSpaceColor } from '..'; const SPACES_DISPLAY_COUNT = 5; +const ALL_SPACES_ID = '*'; const UNKNOWN_SPACE = '?'; type SpaceMap = Map; @@ -31,60 +32,77 @@ const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { return null; } - const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; - const authorizedSpaceTargets: SpaceTarget[] = []; - authorized.forEach((namespace) => { - const spaceTarget = data.get(namespace); - if (spaceTarget === undefined) { - // in the event that a new space was created after this page has loaded, fall back to displaying the space ID - authorizedSpaceTargets.push({ id: namespace, name: namespace, isActiveSpace: false }); - } else if (!spaceTarget.isActiveSpace) { - authorizedSpaceTargets.push(spaceTarget); - } - }); + const isSharedToAllSpaces = namespaces?.includes(ALL_SPACES_ID); const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) .length; - const unauthorizedTooltip = i18n.translate( - 'xpack.spaces.management.shareToSpace.columnUnauthorizedLabel', - { defaultMessage: `You don't have permission to view these spaces.` } - ); + let displayedSpaces: SpaceTarget[]; + let button: ReactNode = null; - const displayedSpaces = isExpanded - ? authorizedSpaceTargets - : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); - const showButton = authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT; + if (isSharedToAllSpaces) { + displayedSpaces = [ + { + id: ALL_SPACES_ID, + name: i18n.translate('xpack.spaces.management.shareToSpace.allSpacesLabel', { + defaultMessage: `* All spaces`, + }), + isActiveSpace: false, + color: '#D3DAE6', + }, + ]; + } else { + const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; + const authorizedSpaceTargets: SpaceTarget[] = []; + authorized.forEach((namespace) => { + const spaceTarget = data.get(namespace); + if (spaceTarget === undefined) { + // in the event that a new space was created after this page has loaded, fall back to displaying the space ID + authorizedSpaceTargets.push({ id: namespace, name: namespace, isActiveSpace: false }); + } else if (!spaceTarget.isActiveSpace) { + authorizedSpaceTargets.push(spaceTarget); + } + }); + displayedSpaces = isExpanded + ? authorizedSpaceTargets + : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); + + if (authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT) { + button = isExpanded ? ( + setIsExpanded(false)}> + + + ) : ( + setIsExpanded(true)}> + + + ); + } + } const unauthorizedCountBadge = - (isExpanded || !showButton) && unauthorizedCount > 0 ? ( + !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedCount > 0 ? ( - + + } + > +{unauthorizedCount} ) : null; - let button: ReactNode = null; - if (showButton) { - button = isExpanded ? ( - setIsExpanded(false)}> - - - ) : ( - setIsExpanded(true)}> - - - ); - } - return ( {displayedSpaces.map(({ id, name, color }) => ( From 8385cdbfc1c2c4d816d756ddcae87f53f5292afd Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 23 Sep 2020 12:33:21 -0400 Subject: [PATCH 08/25] Clean up "Share to space" routes, add unit tests No functionality changes, just some refactoring. --- .../components/no_spaces_available.tsx | 32 +++ .../components/share_to_space_flyout.test.tsx | 27 +- .../components/share_to_space_flyout.tsx | 22 +- .../__fixtures__/create_mock_so_service.ts | 73 +++-- .../routes/api/external/copy_to_space.test.ts | 3 +- .../server/routes/api/external/index.ts | 6 +- .../routes/api/external/share_add_spaces.ts | 63 ----- .../api/external/share_remove_spaces.ts | 63 ----- .../api/external/share_to_space.test.ts | 253 ++++++++++++++++++ .../routes/api/external/share_to_space.ts | 75 ++++++ 10 files changed, 415 insertions(+), 202 deletions(-) create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx delete mode 100644 x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts delete mode 100644 x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts create mode 100644 x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts create mode 100644 x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx new file mode 100644 index 00000000000000..58f0feea3d2f37 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const NoSpacesAvailable = () => { + return ( + + +

+ } + title={ +

+ +

+ } + /> + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx index 27656cd912695e..804b1a88d69313 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx @@ -8,7 +8,7 @@ import Boom from 'boom'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; import { ShareToSpaceForm } from './share_to_space_form'; -import { EuiLoadingSpinner, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { Space } from '../../../common/model/space'; import { findTestSubject } from 'test_utils/find_test_subject'; import { SelectableSpacesControl } from './selectable_spaces_control'; @@ -18,6 +18,7 @@ import { SpacesManager } from '../../spaces_manager'; import { ToastsApi } from 'src/core/public'; import { EuiCallOut } from '@elastic/eui'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { NoSpacesAvailable } from './no_spaces_available'; import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; interface SetupOpts { @@ -111,7 +112,7 @@ describe('ShareToSpaceFlyout', () => { const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); await act(async () => { @@ -121,26 +122,26 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); }); - it('shows a message within an EuiEmptyPrompt when no spaces are available', async () => { + it('shows a message within a NoSpacesAvailable when no spaces are available', async () => { const { wrapper, onClose } = await setup({ mockSpaces: [] }); expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); expect(onClose).toHaveBeenCalledTimes(0); }); - it('shows a message within an EuiEmptyPrompt when only the active space is available', async () => { + it('shows a message within a NoSpacesAvailable when only the active space is available', async () => { const { wrapper, onClose } = await setup({ mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], }); expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); expect(onClose).toHaveBeenCalledTimes(0); }); @@ -176,7 +177,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout @@ -199,7 +200,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).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 @@ -230,7 +231,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).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 @@ -263,7 +264,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).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 @@ -302,7 +303,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).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 @@ -341,7 +342,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).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 diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx index 053fcb4fdabf80..4b5c144323ba79 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -27,6 +27,7 @@ import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/save import { Space } from '../../../common/model/space'; import { SpacesManager } from '../../spaces_manager'; import { ShareToSpaceForm } from './share_to_space_form'; +import { NoSpacesAvailable } from './no_spaces_available'; import { ShareOptions, SpaceTarget } from '../types'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; @@ -169,26 +170,7 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { // Step 1a: assets loaded, but no spaces are available for share. // The `spaces` array includes the current space, so at minimum it will have a length of 1. if (spaces.length < 2) { - return ( - - -

- } - title={ -

- -

- } - /> - ); + return ; } const showShareWarning = currentNamespaces.length === 1; diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts index ce93591f492f16..fa8ef1882099cf 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts @@ -4,48 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; -import { coreMock, savedObjectsTypeRegistryMock } from '../../../../../../../src/core/server/mocks'; +import { SavedObject, SavedObjectsUpdateResponse, SavedObjectsErrorHelpers } from 'src/core/server'; +import { + coreMock, + savedObjectsClientMock, + savedObjectsTypeRegistryMock, +} from '../../../../../../../src/core/server/mocks'; export const createMockSavedObjectsService = (spaces: any[] = []) => { - const mockSavedObjectsClientContract = ({ - get: jest.fn((type, id) => { - const result = spaces.filter((s) => s.id === id); - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return result[0]; - }), - find: jest.fn(() => { - return { - total: spaces.length, - saved_objects: spaces, - }; - }), - create: jest.fn((type, attributes, { id }) => { - if (spaces.find((s) => s.id === id)) { - throw SavedObjectsErrorHelpers.decorateConflictError(new Error(), 'space conflict'); - } - return {}; - }), - update: jest.fn((type, id) => { - if (!spaces.find((s) => s.id === id)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return {}; - }), - delete: jest.fn((type: string, id: string) => { - return {}; - }), - deleteByNamespace: jest.fn(), - } as unknown) as jest.Mocked; + const typeRegistry = savedObjectsTypeRegistryMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockImplementation((type, id) => { + const result = spaces.filter((s) => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return Promise.resolve(result[0]); + }); + savedObjectsClient.find.mockResolvedValue({ + page: 1, + per_page: 20, + total: spaces.length, + saved_objects: spaces, + }); + savedObjectsClient.create.mockImplementation((_type, _attributes, options) => { + if (spaces.find((s) => s.id === options?.id)) { + throw SavedObjectsErrorHelpers.decorateConflictError(new Error(), 'space conflict'); + } + return Promise.resolve({} as SavedObject); + }); + savedObjectsClient.update.mockImplementation((type, id, _attributes, _options) => { + if (!spaces.find((s) => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return Promise.resolve({} as SavedObjectsUpdateResponse); + }); const { savedObjects } = coreMock.createStart(); - - const typeRegistry = savedObjectsTypeRegistryMock.create(); savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); + savedObjects.getScopedClient.mockReturnValue(savedObjectsClient); - savedObjects.getScopedClient.mockReturnValue(mockSavedObjectsClientContract); - - return savedObjects; + return { savedObjects, typeRegistry, savedObjectsClient }; }; diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index dce6de908cfcbc..bc1e4c3fe4a447 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -71,7 +71,8 @@ describe('copy to space', () => { const log = loggingSystemMock.create().get('spaces'); const coreStart = coreMock.createStart(); - coreStart.savedObjects = createMockSavedObjectsService(spaces); + const { savedObjects } = createMockSavedObjectsService(spaces); + coreStart.savedObjects = savedObjects; const service = new SpacesService(log); const spacesService = await service.setup({ 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 079f690bfe5463..dd93cffd28dd70 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts @@ -12,8 +12,7 @@ import { initPostSpacesApi } from './post'; import { initPutSpacesApi } from './put'; import { SpacesServiceSetup } from '../../../spaces_service/spaces_service'; import { initCopyToSpacesApi } from './copy_to_space'; -import { initShareAddSpacesApi } from './share_add_spaces'; -import { initShareRemoveSpacesApi } from './share_remove_spaces'; +import { initShareToSpacesApi } from './share_to_space'; export interface ExternalRouteDeps { externalRouter: IRouter; @@ -30,6 +29,5 @@ export function initExternalSpacesApi(deps: ExternalRouteDeps) { initPostSpacesApi(deps); initPutSpacesApi(deps); initCopyToSpacesApi(deps); - initShareAddSpacesApi(deps); - initShareRemoveSpacesApi(deps); + initShareToSpacesApi(deps); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts deleted file mode 100644 index 3f4e439a8d683e..00000000000000 --- a/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts +++ /dev/null @@ -1,63 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { wrapError } from '../../../lib/errors'; -import { ExternalRouteDeps } from '.'; -import { SPACE_ID_REGEX } from '../../../lib/space_schema'; -import { createLicensedRouteHandler } from '../../lib'; -import { ALL_SPACES_STRING } from '../../../lib/utils/namespace'; - -const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); -export function initShareAddSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, getStartServices } = deps; - - externalRouter.post( - { - path: '/api/spaces/_share_saved_object_add', - validate: { - body: schema.object({ - spaces: schema.arrayOf( - schema.string({ - validate: (value) => { - if (value !== ALL_SPACES_STRING && !SPACE_ID_REGEX.test(value)) { - return `lower case, a-z, 0-9, "_", and "-" are allowed, OR "*"`; - } - }, - }), - { - validate: (spaceIds) => { - if (!spaceIds.length) { - return 'must specify one or more space ids'; - } else if (uniq(spaceIds).length !== spaceIds.length) { - return 'duplicate space ids are not allowed'; - } - }, - } - ), - object: schema.object({ - type: schema.string(), - id: schema.string(), - }), - }), - }, - }, - createLicensedRouteHandler(async (_context, request, response) => { - const [startServices] = await getStartServices(); - const scopedClient = startServices.savedObjects.getScopedClient(request); - - const spaces = request.body.spaces; - const { type, id } = request.body.object; - - try { - await scopedClient.addToNamespaces(type, id, spaces); - } catch (error) { - return response.customError(wrapError(error)); - } - return response.noContent(); - }) - ); -} diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts deleted file mode 100644 index e2e261ef5b8279..00000000000000 --- a/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts +++ /dev/null @@ -1,63 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { wrapError } from '../../../lib/errors'; -import { ExternalRouteDeps } from '.'; -import { SPACE_ID_REGEX } from '../../../lib/space_schema'; -import { createLicensedRouteHandler } from '../../lib'; -import { ALL_SPACES_STRING } from '../../../lib/utils/namespace'; - -const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); -export function initShareRemoveSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, getStartServices } = deps; - - externalRouter.post( - { - path: '/api/spaces/_share_saved_object_remove', - validate: { - body: schema.object({ - spaces: schema.arrayOf( - schema.string({ - validate: (value) => { - if (value !== ALL_SPACES_STRING && !SPACE_ID_REGEX.test(value)) { - return `lower case, a-z, 0-9, "_", and "-" are allowed, OR "*"`; - } - }, - }), - { - validate: (spaceIds) => { - if (!spaceIds.length) { - return 'must specify one or more space ids'; - } else if (uniq(spaceIds).length !== spaceIds.length) { - return 'duplicate space ids are not allowed'; - } - }, - } - ), - object: schema.object({ - type: schema.string(), - id: schema.string(), - }), - }), - }, - }, - createLicensedRouteHandler(async (_context, request, response) => { - const [startServices] = await getStartServices(); - const scopedClient = startServices.savedObjects.getScopedClient(request); - - const spaces = request.body.spaces; - const { type, id } = request.body.object; - - try { - await scopedClient.deleteFromNamespaces(type, id, spaces); - } catch (error) { - return response.customError(wrapError(error)); - } - return response.noContent(); - }) - ); -} diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts new file mode 100644 index 00000000000000..e330cd7c660c28 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as Rx from 'rxjs'; +import { + createSpaces, + createMockSavedObjectsRepository, + mockRouteContext, + mockRouteContextWithInvalidLicense, + createMockSavedObjectsService, +} from '../__fixtures__'; +import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; +import { + loggingSystemMock, + httpServiceMock, + httpServerMock, + coreMock, +} from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initShareToSpacesApi } from './share_to_space'; +import { spacesConfig } from '../../../lib/__fixtures__'; +import { securityMock } from '../../../../../security/server/mocks'; +import { ObjectType } from '@kbn/config-schema'; + +describe('share to space', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map((s) => ({ id: s.id, ...s.attributes })); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter(); + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + const log = loggingSystemMock.create().get('spaces'); + const coreStart = coreMock.createStart(); + const { savedObjects, savedObjectsClient } = createMockSavedObjectsService(spaces); + coreStart.savedObjects = savedObjects; + + const service = new SpacesService(log); + const spacesService = await service.setup({ + http: (httpService as unknown) as CoreSetup['http'], + getStartServices: async () => [coreStart, {}, {}], + authorization: securityMock.createSetup().authz, + auditLogger: {} as SpacesAuditLogger, + config$: Rx.of(spacesConfig), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + spacesConfig, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initShareToSpacesApi({ + externalRouter: router, + getStartServices: async () => [coreStart, {}, {}], + getImportExportObjectLimit: () => 1000, + log, + spacesService, + }); + + const [ + [shareAdd, ctsRouteHandler], + [shareRemove, resolveRouteHandler], + ] = router.post.mock.calls; + + return { + coreStart, + savedObjectsClient, + shareAdd: { + routeValidation: shareAdd.validate as RouteValidatorConfig<{}, {}, {}>, + routeHandler: ctsRouteHandler, + }, + shareRemove: { + routeValidation: shareRemove.validate as RouteValidatorConfig<{}, {}, {}>, + routeHandler: resolveRouteHandler, + }, + savedObjectsRepositoryMock, + }; + }; + + describe('POST /api/spaces/_share_saved_object_add', () => { + const object = { id: 'foo', type: 'bar' }; + + it(`returns http/403 when the license is invalid`, async () => { + const { shareAdd } = await setup(); + + const request = httpServerMock.createKibanaRequest({ method: 'post' }); + const response = await shareAdd.routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it(`requires at least 1 space ID`, async () => { + const { shareAdd } = await setup(); + const payload = { spaces: [], object }; + + expect(() => + (shareAdd.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: must specify one or more space ids"`); + }); + + it(`requires space IDs to be unique`, async () => { + const { shareAdd } = await setup(); + const payload = { spaces: ['a-space', 'a-space'], object }; + + expect(() => + (shareAdd.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: duplicate space ids are not allowed"`); + }); + + it(`requires well-formed space IDS`, async () => { + const { shareAdd } = await setup(); + const payload = { spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], object }; + + expect(() => + (shareAdd.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot( + `"[spaces.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed, OR \\"*\\""` + ); + }); + + it(`allows all spaces ("*")`, async () => { + const { shareAdd } = await setup(); + const payload = { spaces: ['*'], object }; + + expect(() => + (shareAdd.routeValidation.body as ObjectType).validate(payload) + ).not.toThrowError(); + }); + + it('adds the object to the specified space(s)', async () => { + const { shareAdd, savedObjectsClient } = await setup(); + const payload = { spaces: ['a-space', 'b-space'], object }; + + const request = httpServerMock.createKibanaRequest({ body: payload, method: 'post' }); + const response = await shareAdd.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status } = response; + expect(status).toEqual(204); + expect(savedObjectsClient.addToNamespaces).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.addToNamespaces).toHaveBeenCalledWith( + payload.object.type, + payload.object.id, + payload.spaces + ); + }); + }); + + describe('POST /api/spaces/_share_saved_object_remove', () => { + const object = { id: 'foo', type: 'bar' }; + + it(`returns http/403 when the license is invalid`, async () => { + const { shareRemove } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + }); + + const response = await shareRemove.routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it(`requires at least 1 space ID`, async () => { + const { shareRemove } = await setup(); + const payload = { spaces: [], object }; + + expect(() => + (shareRemove.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: must specify one or more space ids"`); + }); + + it(`requires space IDs to be unique`, async () => { + const { shareRemove } = await setup(); + const payload = { spaces: ['a-space', 'a-space'], object }; + + expect(() => + (shareRemove.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: duplicate space ids are not allowed"`); + }); + + it(`requires well-formed space IDS`, async () => { + const { shareRemove } = await setup(); + const payload = { spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], object }; + + expect(() => + (shareRemove.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot( + `"[spaces.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed, OR \\"*\\""` + ); + }); + + it(`allows all spaces ("*")`, async () => { + const { shareRemove } = await setup(); + const payload = { spaces: ['*'], object }; + + expect(() => + (shareRemove.routeValidation.body as ObjectType).validate(payload) + ).not.toThrowError(); + }); + + it('removes the object from the specified space(s)', async () => { + const { shareRemove, savedObjectsClient } = await setup(); + const payload = { spaces: ['a-space', 'b-space'], object }; + + const request = httpServerMock.createKibanaRequest({ body: payload, method: 'post' }); + const response = await shareRemove.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status } = response; + expect(status).toEqual(204); + expect(savedObjectsClient.deleteFromNamespaces).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.deleteFromNamespaces).toHaveBeenCalledWith( + payload.object.type, + payload.object.id, + payload.spaces + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts new file mode 100644 index 00000000000000..1ffa94cfe80be8 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { wrapError } from '../../../lib/errors'; +import { ExternalRouteDeps } from '.'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; +import { createLicensedRouteHandler } from '../../lib'; +import { ALL_SPACES_STRING } from '../../../lib/utils/namespace'; + +const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); +export function initShareToSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, getStartServices } = deps; + + const shareSchema = schema.object({ + spaces: schema.arrayOf( + schema.string({ + validate: (value) => { + if (value !== ALL_SPACES_STRING && !SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed, OR "*"`; + } + }, + }), + { + validate: (spaceIds) => { + if (!spaceIds.length) { + return 'must specify one or more space ids'; + } else if (uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + object: schema.object({ type: schema.string(), id: schema.string() }), + }); + + externalRouter.post( + { path: '/api/spaces/_share_saved_object_add', validate: { body: shareSchema } }, + createLicensedRouteHandler(async (_context, request, response) => { + const [startServices] = await getStartServices(); + const scopedClient = startServices.savedObjects.getScopedClient(request); + + const spaces = request.body.spaces; + const { type, id } = request.body.object; + + try { + await scopedClient.addToNamespaces(type, id, spaces); + } catch (error) { + return response.customError(wrapError(error)); + } + return response.noContent(); + }) + ); + + externalRouter.post( + { path: '/api/spaces/_share_saved_object_remove', validate: { body: shareSchema } }, + createLicensedRouteHandler(async (_context, request, response) => { + const [startServices] = await getStartServices(); + const scopedClient = startServices.savedObjects.getScopedClient(request); + + const spaces = request.body.spaces; + const { type, id } = request.body.object; + + try { + await scopedClient.deleteFromNamespaces(type, id, spaces); + } catch (error) { + return response.customError(wrapError(error)); + } + return response.noContent(); + }) + ); +} From b79aff1625c14039a83059f23e674632bcf2482c Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 23 Sep 2020 12:51:42 -0400 Subject: [PATCH 09/25] Remove dead code --- .../lib/spaces_client/spaces_client.mock.ts | 1 - .../lib/spaces_client/spaces_client.test.ts | 125 ------------------ .../server/lib/spaces_client/spaces_client.ts | 15 --- 3 files changed, 141 deletions(-) diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts index 10f6292abf319b..e38842b8799acc 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts @@ -10,7 +10,6 @@ import { SpacesClient } from './spaces_client'; const createSpacesClientMock = () => (({ - canEnumerateSpaces: jest.fn().mockResolvedValue(true), getAll: jest.fn().mockResolvedValue([ { id: DEFAULT_SPACE_ID, diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 1090b029069d21..4502b081034aa4 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -375,131 +375,6 @@ describe('#getAll', () => { }); }); -describe('#canEnumerateSpaces', () => { - describe(`authorization is null`, () => { - test(`returns true`, async () => { - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const authorization = null; - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - authorization, - null, - mockConfig, - null, - request - ); - - const canEnumerateSpaces = await client.canEnumerateSpaces(); - expect(canEnumerateSpaces).toEqual(true); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); - }); - }); - - describe(`authorization.mode.useRbacForRequest is false`, () => { - test(`returns true`, async () => { - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const { mockAuthorization } = createMockAuthorization(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - mockAuthorization, - null, - mockConfig, - null, - request - ); - const canEnumerateSpaces = await client.canEnumerateSpaces(); - - expect(canEnumerateSpaces).toEqual(true); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); - }); - }); - - describe('useRbacForRequest is true', () => { - test(`returns false if user is not authorized to enumerate spaces`, async () => { - const username = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesGlobally.mockReturnValue({ - username, - hasAllRequested: false, - }); - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - mockAuthorization, - null, - mockConfig, - null, - request - ); - - const canEnumerateSpaces = await client.canEnumerateSpaces(); - expect(canEnumerateSpaces).toEqual(false); - - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); - }); - - test(`returns true if user is authorized to enumerate spaces`, async () => { - const username = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesGlobally.mockReturnValue({ - username, - hasAllRequested: true, - }); - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - mockAuthorization, - null, - mockConfig, - null, - request - ); - - const canEnumerateSpaces = await client.canEnumerateSpaces(); - expect(canEnumerateSpaces).toEqual(true); - - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); - }); - }); -}); - describe('#get', () => { const savedObject = { id: 'foo', diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index 5ef0b5375d7969..50e7182b766863 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -47,21 +47,6 @@ export class SpacesClient { private readonly request: KibanaRequest ) {} - public async canEnumerateSpaces(): Promise { - if (this.useRbac()) { - const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { hasAllRequested } = await checkPrivileges.globally({ - kibana: this.authorization!.actions.space.manage, - }); - this.debugLogger(`SpacesClient.canEnumerateSpaces, using RBAC. Result: ${hasAllRequested}`); - return hasAllRequested; - } - - // If not RBAC, then security isn't enabled and we can enumerate all spaces - this.debugLogger(`SpacesClient.canEnumerateSpaces, NOT USING RBAC. Result: true`); - return true; - } - public async getAll(purpose: GetSpacePurpose = 'any'): Promise { if (!SUPPORTED_GET_SPACE_PURPOSES.includes(purpose)) { throw Boom.badRequest(`unsupported space purpose: ${purpose}`); From 1deac869fd235f738511b55b89a33f76530614cd Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 23 Sep 2020 13:32:34 -0400 Subject: [PATCH 10/25] Clean up saved object authorization unit tests / errors One error message was incorrect. Converted to get rid of snapshots. --- .../__snapshots__/saved_object.test.ts.snap | 25 ------------------- .../actions/saved_object.test.ts | 8 ++++-- .../authorization/actions/saved_object.ts | 2 +- 3 files changed, 7 insertions(+), 28 deletions(-) delete mode 100644 x-pack/plugins/security/server/authorization/actions/__snapshots__/saved_object.test.ts.snap diff --git a/x-pack/plugins/security/server/authorization/actions/__snapshots__/saved_object.test.ts.snap b/x-pack/plugins/security/server/authorization/actions/__snapshots__/saved_object.test.ts.snap deleted file mode 100644 index 117947fc22f50e..00000000000000 --- a/x-pack/plugins/security/server/authorization/actions/__snapshots__/saved_object.test.ts.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#get operation of "" throws error 1`] = `"type is required and must be a string"`; - -exports[`#get operation of {} throws error 1`] = `"type is required and must be a string"`; - -exports[`#get operation of 1 throws error 1`] = `"type is required and must be a string"`; - -exports[`#get operation of null throws error 1`] = `"type is required and must be a string"`; - -exports[`#get operation of true throws error 1`] = `"type is required and must be a string"`; - -exports[`#get operation of undefined throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of "" throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of {} throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of 1 throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of null throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of true throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of undefined throws error 1`] = `"type is required and must be a string"`; diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts index 9e8bfb6ad795f1..90448a5dd0422d 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts @@ -12,14 +12,18 @@ describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((type: any) => { test(`type of ${JSON.stringify(type)} throws error`, () => { const savedObjectActions = new SavedObjectActions(version); - expect(() => savedObjectActions.get(type, 'foo-action')).toThrowErrorMatchingSnapshot(); + expect(() => savedObjectActions.get(type, 'foo-action')).toThrowError( + 'type is required and must be a string' + ); }); }); [null, undefined, '', 1, true, {}].forEach((operation: any) => { test(`operation of ${JSON.stringify(operation)} throws error`, () => { const savedObjectActions = new SavedObjectActions(version); - expect(() => savedObjectActions.get('foo-type', operation)).toThrowErrorMatchingSnapshot(); + expect(() => savedObjectActions.get('foo-type', operation)).toThrowError( + 'operation is required and must be a string' + ); }); }); diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.ts index e3a02d38073998..6bd094d64ec744 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.ts @@ -19,7 +19,7 @@ export class SavedObjectActions { } if (!operation || !isString(operation)) { - throw new Error('type is required and must be a string'); + throw new Error('operation is required and must be a string'); } return `${this.prefix}${type}/${operation}`; From a15abffd4b3d5f896819ed622b9dcab0254991ee Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 30 Sep 2020 09:51:10 -0400 Subject: [PATCH 11/25] Change "Share to space" flyout to support sharing to all spaces * Added new checkable card options * Added privilege checks which conditionally disable options * Added descriptive text when unknown spaces are selected --- x-pack/plugins/spaces/public/plugin.tsx | 1 + .../components/no_spaces_available.tsx | 52 +++--- .../components/selectable_spaces_control.tsx | 158 ++++++++++++++---- .../components/share_mode_control.scss | 3 + .../components/share_mode_control.tsx | 153 +++++++++++++++++ .../components/share_to_space_flyout.test.tsx | 14 +- .../components/share_to_space_flyout.tsx | 115 ++++++++----- .../components/share_to_space_form.tsx | 52 +++--- .../share_saved_objects_to_space_action.tsx | 7 +- ...are_saved_objects_to_space_service.test.ts | 3 +- .../share_saved_objects_to_space_service.ts | 17 +- .../spaces_manager/spaces_manager.mock.ts | 1 + .../spaces_manager/spaces_manager.test.ts | 20 +++ .../public/spaces_manager/spaces_manager.ts | 6 + .../lib/spaces_client/spaces_client.test.ts | 119 +++++++++++++ .../server/lib/spaces_client/spaces_client.ts | 21 +++ .../routes/api/external/share_to_space.ts | 21 ++- 17 files changed, 632 insertions(+), 131 deletions(-) create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.scss create mode 100644 x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index cd31a4aa17fc38..1d86d0664407ae 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -73,6 +73,7 @@ export class SpacesPlugin implements Plugin['getStartServices'], }); const copySavedObjectsToSpaceService = new CopySavedObjectsToSpaceService(); copySavedObjectsToSpaceService.setup({ diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx index 58f0feea3d2f37..52fc0bb32f5a46 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx @@ -5,28 +5,40 @@ */ import React from 'react'; -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiLink, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ApplicationStart } from 'src/core/public'; + +interface Props { + application: ApplicationStart; +} + +export const NoSpacesAvailable = (props: Props) => { + const { capabilities, getUrlForApp } = props.application; + const canCreateNewSpaces = capabilities.spaces?.manage; + if (!canCreateNewSpaces) { + return null; + } -export const NoSpacesAvailable = () => { return ( - - -

- } - title={ -

- -

- } - /> + + + + + ), + }} + /> + ); }; 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 82a30dabe5beb8..9ba4a7c537a041 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 @@ -5,20 +5,37 @@ */ import './selectable_spaces_control.scss'; -import React, { Fragment } from 'react'; -import { EuiBadge, EuiSelectable, EuiSelectableOption, EuiLoadingSpinner } from '@elastic/eui'; +import React from 'react'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiLink, + EuiSelectable, + EuiSelectableOption, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CoreStart } from 'src/core/public'; +import { NoSpacesAvailable } from './no_spaces_available'; import { SpaceAvatar } from '../../space_avatar'; import { SpaceTarget } from '../types'; interface Props { + coreStart: CoreStart; spaces: SpaceTarget[]; selectedSpaceIds: string[]; onChange: (selectedSpaceIds: string[]) => void; - disabled?: boolean; } type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; +const ALL_SPACES_ID = '*'; +const UNKNOWN_SPACE = '?'; +const ROW_HEIGHT = 40; const activeSpaceProps = { append: Current, disabled: true, @@ -26,51 +43,124 @@ const activeSpaceProps = { }; export const SelectableSpacesControl = (props: Props) => { - if (props.spaces.length === 0) { - return ; - } + const { coreStart, spaces, selectedSpaceIds, onChange } = props; + const { application, docLinks } = coreStart; - const options = props.spaces + const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); + const options = spaces .sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0)) .map((space) => ({ label: space.name, prepend: , - checked: props.selectedSpaceIds.includes(space.id) ? 'on' : undefined, + checked: selectedSpaceIds.includes(space.id) ? 'on' : undefined, ['data-space-id']: space.id, ['data-test-subj']: `sts-space-selector-row-${space.id}`, ...(space.isActiveSpace ? activeSpaceProps : {}), + ...(isGlobalControlChecked && { disabled: true }), })); - function updateSelectedSpaces(selectedOptions: SpaceOption[]) { - if (props.disabled) return; - - const selectedSpaceIds = selectedOptions - .filter((opt) => opt.checked && !opt.disabled) - .map((opt) => opt['data-space-id']); + function updateSelectedSpaces(spaceOptions: SpaceOption[]) { + const selectedOptions = spaceOptions + .filter(({ checked, disabled }) => checked && !disabled) + .map((x) => x['data-space-id']); + const updatedSpaceIds = [ + ...selectedOptions, + ...selectedSpaceIds.filter((x) => x === UNKNOWN_SPACE), // preserve any unknown spaces (to keep the "selected spaces" count accurate) + ]; - props.onChange(selectedSpaceIds); + onChange(updatedSpaceIds); } + const getUnknownSpacesLabel = () => { + const count = selectedSpaceIds.filter((id) => id === UNKNOWN_SPACE).length; + if (!count) { + return null; + } + + const kibanaPrivilegesUrl = `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-privileges.html`; + return ( + <> + + + + + + ), + }} + /> + + + ); + }; + const getNoSpacesAvailable = () => { + if (spaces.length < 2) { + return ; + } + return null; + }; + + const selectedCount = + selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + 1; + const hiddenCount = selectedSpaceIds.filter((id) => id === UNKNOWN_SPACE).length; + const selectSpacesLabel = i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel', + { defaultMessage: 'Select spaces' } + ); + const selectedSpacesLabel = i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel', + { defaultMessage: '{selectedCount} selected', values: { selectedCount } } + ); + const hiddenSpacesLabel = i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel', + { defaultMessage: '({hiddenCount} hidden)', values: { hiddenCount } } + ); + const hiddenSpaces = hiddenCount ? {hiddenSpacesLabel} : null; return ( - updateSelectedSpaces(newOptions as SpaceOption[])} - listProps={{ - bordered: true, - rowHeight: 40, - className: 'spcShareToSpace__spacesList', - 'data-test-subj': 'sts-form-space-selector', - }} - searchable + + + {selectedSpacesLabel} + + {hiddenSpaces} +
+ } + fullWidth > - {(list, search) => { - return ( - - {search} - {list} - - ); - }} - + <> + 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 + > + {(list, search) => { + return ( + <> + {search} + {list} + + ); + }} + + {getUnknownSpacesLabel()} + {getNoSpacesAvailable()} + + ); }; 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 new file mode 100644 index 00000000000000..3baa21f68d4f36 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.scss @@ -0,0 +1,3 @@ +.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 new file mode 100644 index 00000000000000..4ad37094b18af1 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './share_mode_control.scss'; +import React from 'react'; +import { + EuiCheckableCard, + EuiFlexGroup, + EuiFlexItem, + EuiFormFieldset, + EuiIconTip, + EuiLoadingSpinner, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { CoreStart } from 'src/core/public'; +import { SelectableSpacesControl } from './selectable_spaces_control'; +import { SpaceTarget } from '../types'; + +interface Props { + coreStart: CoreStart; + spaces: SpaceTarget[]; + canShareToAllSpaces: boolean; + selectedSpaceIds: string[]; + onChange: (selectedSpaceIds: string[]) => void; + disabled?: boolean; +} + +const ALL_SPACES_ID = '*'; + +function createLabel({ + title, + text, + disabled, + tooltip, +}: { + title: string; + text: string; + disabled: boolean; + tooltip?: string; +}) { + return ( + <> + + + {title} + + {tooltip && ( + + + + )} + + + + {text} + + + ); +} + +export const ShareModeControl = (props: Props) => { + const { spaces, canShareToAllSpaces, selectedSpaceIds, onChange } = props; + + if (spaces.length === 0) { + return ; + } + + const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); + const shareToAllSpaces = { + id: 'shareToAllSpaces', + title: i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title', + { defaultMessage: 'All spaces' } + ), + text: i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text', + { defaultMessage: 'Make object available in all current and future spaces.' } + ), + ...(!canShareToAllSpaces && { + tooltip: isGlobalControlChecked + ? i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', + { defaultMessage: 'You need additional privileges to change this option.' } + ) + : i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', + { defaultMessage: 'You need additional privileges to use this option.' } + ), + }), + disabled: !canShareToAllSpaces, + }; + const shareToExplicitSpaces = { + id: 'shareToExplicitSpaces', + title: i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title', + { defaultMessage: 'Select spaces' } + ), + text: i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text', + { defaultMessage: 'Make object available in selected spaces only.' } + ), + disabled: !canShareToAllSpaces && isGlobalControlChecked, + }; + const shareOptionsTitle = i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle', + { defaultMessage: 'Share options' } + ); + + const toggleShareOption = (allSpaces: boolean) => { + const updatedSpaceIds = allSpaces + ? [ALL_SPACES_ID, ...selectedSpaceIds] + : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID); + onChange(updatedSpaceIds); + }; + + return ( + <> + + {shareOptionsTitle} + + ), + }} + > + toggleShareOption(false)} + disabled={shareToExplicitSpaces.disabled} + > + + + + toggleShareOption(true)} + disabled={shareToAllSpaces.disabled} + /> + + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx index 804b1a88d69313..13826a519b1e84 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx @@ -15,6 +15,7 @@ import { SelectableSpacesControl } from './selectable_spaces_control'; import { act } from '@testing-library/react'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; +import { coreMock } from '../../../../../../src/core/public/mocks'; import { ToastsApi } from 'src/core/public'; import { EuiCallOut } from '@elastic/eui'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; @@ -64,6 +65,8 @@ const setup = async (opts: SetupOpts = {}) => { ] ); + mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ shareToAllSpaces: true }); + const mockToastNotifications = { addError: jest.fn(), addSuccess: jest.fn(), @@ -82,6 +85,8 @@ const setup = async (opts: SetupOpts = {}) => { namespaces: opts.namespaces || ['my-active-space', 'space-1'], } as SavedObjectsManagementRecord; + const { getStartServices } = coreMock.createSetup(); + const wrapper = mountWithIntl( { toastNotifications={(mockToastNotifications as unknown) as ToastsApi} onClose={onClose} onObjectUpdated={onObjectUpdated} + getStartServices={getStartServices} /> ); @@ -126,9 +132,11 @@ describe('ShareToSpaceFlyout', () => { }); it('shows a message within a NoSpacesAvailable when no spaces are available', async () => { - const { wrapper, onClose } = await setup({ mockSpaces: [] }); + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], + }); - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); expect(onClose).toHaveBeenCalledTimes(0); @@ -139,7 +147,7 @@ describe('ShareToSpaceFlyout', () => { mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], }); - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); expect(onClose).toHaveBeenCalledTimes(0); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx index 4b5c144323ba79..929f33cdf021b0 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -16,20 +16,19 @@ import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, - EuiEmptyPrompt, EuiButton, EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ToastsStart } from 'src/core/public'; +import { ToastsStart, StartServicesAccessor, CoreStart } from 'src/core/public'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; import { Space } from '../../../common/model/space'; import { SpacesManager } from '../../spaces_manager'; import { ShareToSpaceForm } from './share_to_space_form'; -import { NoSpacesAvailable } from './no_spaces_available'; import { ShareOptions, SpaceTarget } from '../types'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { PluginsStart } from '../../plugin'; interface Props { onClose: () => void; @@ -37,15 +36,26 @@ interface Props { savedObject: SavedObjectsManagementRecord; spacesManager: SpacesManager; toastNotifications: ToastsStart; + getStartServices: StartServicesAccessor; } +const ALL_SPACES_ID = '*'; const arraysAreEqual = (a: unknown[], b: unknown[]) => a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { - const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props; + const { + getStartServices, + onClose, + onObjectUpdated, + savedObject, + spacesManager, + toastNotifications, + } = props; const { namespaces: currentNamespaces = [] } = savedObject; + const [coreStart, setCoreStart] = useState(); const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); + const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); const [showMakeCopy, setShowMakeCopy] = useState(false); const [{ isLoading, spaces }, setSpacesState] = useState<{ @@ -55,8 +65,15 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { useEffect(() => { const getSpaces = spacesManager.getSpaces('shareSavedObjectsIntoSpace'); const getActiveSpace = spacesManager.getActiveSpace(); - Promise.all([getSpaces, getActiveSpace]) - .then(([allSpaces, activeSpace]) => { + const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObject.type); + Promise.all([getSpaces, getActiveSpace, getPermissions, getStartServices()]) + .then(([allSpaces, activeSpace, permissions, startServices]) => { + const [coreStartValue] = startServices; + setCoreStart(coreStartValue); + setShareOptions({ + selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), + }); + setCanShareToAllSpaces(permissions.shareToAllSpaces); const createSpaceTarget = (space: Space): SpaceTarget => ({ ...space, isActiveSpace: space.id === activeSpace.id, @@ -65,9 +82,6 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { isLoading: false, spaces: allSpaces.map((space) => createSpaceTarget(space)), }); - setShareOptions({ - selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), - }); }) .catch((e) => { toastNotifications.addError(e, { @@ -76,25 +90,50 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { }), }); }); - }, [currentNamespaces, spacesManager, toastNotifications]); + }, [currentNamespaces, spacesManager, savedObject, toastNotifications, getStartServices]); const getSelectionChanges = () => { const activeSpace = spaces.find((space) => space.isActiveSpace); if (!activeSpace) { - return { changed: false, spacesToAdd: [], spacesToRemove: [] }; + return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; } const initialSelection = currentNamespaces.filter( (spaceId) => spaceId !== activeSpace.id && spaceId !== '?' ); const { selectedSpaceIds } = shareOptions; - const changed = !arraysAreEqual(initialSelection, selectedSpaceIds); - const spacesToAdd = selectedSpaceIds.filter((spaceId) => !initialSelection.includes(spaceId)); - const spacesToRemove = initialSelection.filter( - (spaceId) => !selectedSpaceIds.includes(spaceId) + const filteredSelection = selectedSpaceIds.filter((x) => x !== '?'); + const isSharedToAllSpaces = + !initialSelection.includes(ALL_SPACES_ID) && filteredSelection.includes(ALL_SPACES_ID); + const isUnsharedFromAllSpaces = + initialSelection.includes(ALL_SPACES_ID) && !filteredSelection.includes(ALL_SPACES_ID); + const selectedSpacesChanged = + !filteredSelection.includes(ALL_SPACES_ID) && + !arraysAreEqual(initialSelection, filteredSelection); + const isSelectionChanged = + isSharedToAllSpaces || + isUnsharedFromAllSpaces || + (!isSharedToAllSpaces && !isUnsharedFromAllSpaces && selectedSpacesChanged); + + const selectedSpacesToAdd = filteredSelection.filter( + (spaceId) => !initialSelection.includes(spaceId) + ); + const selectedSpacesToRemove = initialSelection.filter( + (spaceId) => !filteredSelection.includes(spaceId) ); - return { changed, spacesToAdd, spacesToRemove }; + + const spacesToAdd = isSharedToAllSpaces + ? [ALL_SPACES_ID] + : isUnsharedFromAllSpaces + ? [activeSpace.id, ...selectedSpacesToAdd] + : selectedSpacesToAdd; + const spacesToRemove = isUnsharedFromAllSpaces + ? [ALL_SPACES_ID] + : isSharedToAllSpaces + ? [activeSpace.id, ...initialSelection] + : selectedSpacesToRemove; + return { isSelectionChanged, spacesToAdd, spacesToRemove }; }; - const { changed: isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); + const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); const [shareInProgress, setShareInProgress] = useState(false); @@ -110,32 +149,28 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { : i18n.translate('xpack.spaces.management.shareToSpace.shareEditSuccessTitle', { defaultMessage: 'Object was updated', }); + const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); if (spacesToAdd.length > 0) { await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); - const spaceNames = spacesToAdd.map( - (spaceId) => spaces.find((space) => space.id === spaceId)!.name - ); - const spaceCount = spaceNames.length; + const spaceTargets = isSharedToAllSpaces ? 'all' : `${spacesToAdd.length}`; const text = - spaceCount === 1 + !isSharedToAllSpaces && spacesToAdd.length === 1 ? i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular', { defaultMessage: `'{object}' was added to 1 space.`, values: { object: meta.title }, }) : i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural', { - defaultMessage: `'{object}' was added to {spaceCount} spaces.`, - values: { object: meta.title, spaceCount }, + defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, + values: { object: meta.title, spaceTargets }, }); toastNotifications.addSuccess({ title, text }); } if (spacesToRemove.length > 0) { await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); - const spaceNames = spacesToRemove.map( - (spaceId) => spaces.find((space) => space.id === spaceId)!.name - ); - const spaceCount = spaceNames.length; + const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); + const spaceTargets = isUnsharedFromAllSpaces ? 'all' : `${spacesToRemove.length}`; const text = - spaceCount === 1 + !isUnsharedFromAllSpaces && spacesToRemove.length === 1 ? i18n.translate( 'xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular', { @@ -144,10 +179,12 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { } ) : i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural', { - defaultMessage: `'{object}' was removed from {spaceCount} spaces.`, - values: { object: meta.title, spaceCount }, + defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, + values: { object: meta.title, spaceTargets }, }); - toastNotifications.addSuccess({ title, text }); + if (!isSharedToAllSpaces) { + toastNotifications.addSuccess({ title, text }); + } } onObjectUpdated(); onClose(); @@ -167,20 +204,18 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { return ; } - // Step 1a: assets loaded, but no spaces are available for share. - // The `spaces` array includes the current space, so at minimum it will have a length of 1. - if (spaces.length < 2) { - return ; - } - - const showShareWarning = currentNamespaces.length === 1; + const activeSpace = spaces.find((x) => x.isActiveSpace)!; + const showShareWarning = + spaces.length > 1 && arraysAreEqual(currentNamespaces, [activeSpace.id]); // Step 2: Share has not been initiated yet; User must fill out form to continue. return ( setShowMakeCopy(true)} /> ); @@ -256,7 +291,7 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { > 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 717f77e562c42e..0aa545f79d99ee 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 @@ -6,25 +6,38 @@ import './share_to_space_form.scss'; import React, { Fragment } from 'react'; -import { EuiHorizontalRule, EuiFormRow, EuiCallOut, EuiLink } from '@elastic/eui'; +import { EuiHorizontalRule, EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { CoreStart } from 'src/core/public'; import { ShareOptions, SpaceTarget } from '../types'; -import { SelectableSpacesControl } from './selectable_spaces_control'; +import { ShareModeControl } from './share_mode_control'; interface Props { + coreStart: CoreStart; spaces: SpaceTarget[]; onUpdate: (shareOptions: ShareOptions) => void; shareOptions: ShareOptions; showShareWarning: boolean; + canShareToAllSpaces: boolean; makeCopy: () => void; } export const ShareToSpaceForm = (props: Props) => { + const { + coreStart, + spaces, + onUpdate, + shareOptions, + showShareWarning, + canShareToAllSpaces, + makeCopy, + } = props; + const setSelectedSpaceIds = (selectedSpaceIds: string[]) => - props.onUpdate({ ...props.shareOptions, selectedSpaceIds }); + onUpdate({ ...shareOptions, selectedSpaceIds }); const getShareWarning = () => { - if (!props.showShareWarning) { + if (!showShareWarning) { return null; } @@ -45,7 +58,7 @@ export const ShareToSpaceForm = (props: Props) => { defaultMessage="To edit in only one space, {makeACopyLink} instead." values={{ makeACopyLink: ( - props.makeCopy()}> + makeCopy()}> {
{getShareWarning()} - - } - labelAppend={ - - } - fullWidth - > - setSelectedSpaceIds(selection)} - /> - + setSelectedSpaceIds(selection)} + />
); }; 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 ba9a6473999df3..677632e942e9ad 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 @@ -5,13 +5,14 @@ */ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { NotificationsStart } from 'src/core/public'; +import { NotificationsStart, StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementAction, SavedObjectsManagementRecord, } from '../../../../../src/plugins/saved_objects_management/public'; import { ShareSavedObjectsToSpaceFlyout } from './components'; import { SpacesManager } from '../spaces_manager'; +import { PluginsStart } from '../plugin'; export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction { public id: string = 'share_saved_objects_to_space'; @@ -39,7 +40,8 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage constructor( private readonly spacesManager: SpacesManager, - private readonly notifications: NotificationsStart + private readonly notifications: NotificationsStart, + private readonly getStartServices: StartServicesAccessor ) { super(); } @@ -56,6 +58,7 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage savedObject={this.record} spacesManager={this.spacesManager} toastNotifications={this.notifications.toasts} + getStartServices={this.getStartServices} /> ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts index 0f0fa7d22214f2..6ce4c49c528af8 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts @@ -8,7 +8,7 @@ import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_ // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; import { spacesManagerMock } from '../spaces_manager/mocks'; import { ShareSavedObjectsToSpaceService } from '.'; -import { notificationServiceMock } from 'src/core/public/mocks'; +import { coreMock, notificationServiceMock } from 'src/core/public/mocks'; import { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; describe('ShareSavedObjectsToSpaceService', () => { @@ -18,6 +18,7 @@ describe('ShareSavedObjectsToSpaceService', () => { spacesManager: spacesManagerMock.create(), notificationsSetup: notificationServiceMock.createSetupContract(), savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), + getStartServices: coreMock.createSetup().getStartServices, }; const service = new ShareSavedObjectsToSpaceService(); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts index 9f6e57c355380b..892731a0c57391 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts @@ -4,21 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { NotificationsSetup } from 'src/core/public'; +import { NotificationsSetup, StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; import { SpacesManager } from '../spaces_manager'; +import { PluginsStart } from '../plugin'; interface SetupDeps { spacesManager: SpacesManager; savedObjectsManagementSetup: SavedObjectsManagementPluginSetup; notificationsSetup: NotificationsSetup; + getStartServices: StartServicesAccessor; } export class ShareSavedObjectsToSpaceService { - public setup({ spacesManager, savedObjectsManagementSetup, notificationsSetup }: SetupDeps) { - const action = new ShareToSpaceSavedObjectsManagementAction(spacesManager, notificationsSetup); + public setup({ + spacesManager, + savedObjectsManagementSetup, + notificationsSetup, + getStartServices, + }: SetupDeps) { + const action = new ShareToSpaceSavedObjectsManagementAction( + spacesManager, + notificationsSetup, + getStartServices + ); savedObjectsManagementSetup.actions.register(action); // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); 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 f666c823bd3654..0888448753353a 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() { shareSavedObjectAdd: jest.fn().mockResolvedValue(undefined), shareSavedObjectRemove: jest.fn().mockResolvedValue(undefined), resolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(undefined), + getShareSavedObjectPermissions: jest.fn().mockResolvedValue(undefined), redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined), } as unknown) as jest.Mocked; } 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 508669361c23ff..06cf3ef17dc820 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 @@ -104,4 +104,24 @@ describe('SpacesManager', () => { ); }); }); + + describe('#getShareSavedObjectPermissions', () => { + it('retrieves share permissions for the specified type and returns result', async () => { + const coreStart = coreMock.createStart(); + const shareToAllSpaces = Symbol(); + coreStart.http.get.mockResolvedValue({ shareToAllSpaces }); + const spacesManager = new SpacesManager(coreStart.http); + expect(coreStart.http.get).toHaveBeenCalledTimes(1); // initial call to get active space + + const result = await spacesManager.getShareSavedObjectPermissions('foo'); + expect(coreStart.http.get).toHaveBeenCalledTimes(2); + expect(coreStart.http.get).toHaveBeenLastCalledWith( + '/api/spaces/_share_saved_object_permissions', + { + query: { type: 'foo' }, + } + ); + expect(result).toEqual({ shareToAllSpaces }); + }); + }); }); 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 2daf9ab420efc5..98b00c58bf27d4 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -106,6 +106,12 @@ export class SpacesManager { }); } + public async getShareSavedObjectPermissions( + type: string + ): Promise<{ shareToAllSpaces: boolean }> { + return this.http.get('/api/spaces/_share_saved_object_permissions', { query: { type } }); + } + public async shareSavedObjectAdd(object: SavedObject, spaces: string[]): Promise { return this.http.post(`/api/spaces/_share_saved_object_add`, { body: JSON.stringify({ object, spaces }), diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 4502b081034aa4..397ef6e20dfa8a 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -1323,3 +1323,122 @@ describe('#delete', () => { }); }); }); + +describe('#hasGlobalAllPrivilegesForObjectType', () => { + const type = 'foo'; + + describe(`authorization is null`, () => { + test(`returns true`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const mockDebugLogger = createMockDebugLogger(); + const mockConfig = createMockConfig(); + const authorization = null; + const request = Symbol() as any; + + const client = new SpacesClient( + mockAuditLogger as any, + mockDebugLogger, + authorization, + null, + mockConfig, + null, + request + ); + + const result = await client.hasGlobalAllPrivilegesForObjectType(type); + expect(result).toEqual(true); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe(`authorization.mode.useRbacForRequest is false`, () => { + test(`returns true`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const mockDebugLogger = createMockDebugLogger(); + const mockConfig = createMockConfig(); + const { mockAuthorization } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); + const request = Symbol() as any; + + const client = new SpacesClient( + mockAuditLogger as any, + mockDebugLogger, + mockAuthorization, + null, + mockConfig, + null, + request + ); + + const result = await client.hasGlobalAllPrivilegesForObjectType(type); + expect(result).toEqual(true); + }); + }); + + describe('useRbacForRequest is true', () => { + test(`returns false if user is not authorized to enumerate spaces`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const mockDebugLogger = createMockDebugLogger(); + const mockConfig = createMockConfig(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ hasAllRequested: false }); + const request = Symbol() as any; + + const client = new SpacesClient( + mockAuditLogger as any, + mockDebugLogger, + mockAuthorization, + null, + mockConfig, + null, + request + ); + + const result = await client.hasGlobalAllPrivilegesForObjectType(type); + expect(result).toEqual(false); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: [ + mockAuthorization.actions.savedObject.get(type, 'create'), + mockAuthorization.actions.savedObject.get(type, 'get'), + mockAuthorization.actions.savedObject.get(type, 'update'), + mockAuthorization.actions.savedObject.get(type, 'delete'), + ], + }); + }); + + test(`returns true if user is authorized to enumerate spaces`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const mockDebugLogger = createMockDebugLogger(); + const mockConfig = createMockConfig(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ hasAllRequested: true }); + const request = Symbol() as any; + + const client = new SpacesClient( + mockAuditLogger as any, + mockDebugLogger, + mockAuthorization, + null, + mockConfig, + null, + request + ); + + const result = await client.hasGlobalAllPrivilegesForObjectType(type); + expect(result).toEqual(true); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ + kibana: [ + mockAuthorization.actions.savedObject.get(type, 'create'), + mockAuthorization.actions.savedObject.get(type, 'get'), + mockAuthorization.actions.savedObject.get(type, 'update'), + mockAuthorization.actions.savedObject.get(type, 'delete'), + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index 50e7182b766863..e0fdc64285d5f1 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -215,6 +215,27 @@ export class SpacesClient { await repository.delete('space', id); } + public async hasGlobalAllPrivilegesForObjectType(type: string) { + if (this.useRbac()) { + const kibanaPrivileges = ['create', 'get', 'update', 'delete'].map((operation) => + this.authorization!.actions.savedObject.get(type, operation) + ); + const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); + const { hasAllRequested } = await checkPrivileges.globally({ kibana: kibanaPrivileges }); + // we do not audit the outcome of this privilege check, because it is called automatically to determine UI capabilities + this.debugLogger( + `SpacesClient.hasGlobalAllPrivilegesForObjectType, using RBAC. Result: ${hasAllRequested}` + ); + return hasAllRequested; + } + + // If not RBAC, then security isn't enabled and we can enumerate all spaces + this.debugLogger( + `SpacesClient.hasGlobalAllPrivilegesForObjectType, NOT USING RBAC. Result: true` + ); + return true; + } + private useRbac(): boolean { return this.authorization != null && this.authorization.mode.useRbacForRequest(this.request); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts index 1ffa94cfe80be8..cc3573896ca8ff 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts @@ -13,7 +13,7 @@ import { ALL_SPACES_STRING } from '../../../lib/utils/namespace'; const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); export function initShareToSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, getStartServices } = deps; + const { externalRouter, getStartServices, spacesService } = deps; const shareSchema = schema.object({ spaces: schema.arrayOf( @@ -37,6 +37,25 @@ export function initShareToSpacesApi(deps: ExternalRouteDeps) { object: schema.object({ type: schema.string(), id: schema.string() }), }); + externalRouter.get( + { + path: '/api/spaces/_share_saved_object_permissions', + validate: { query: schema.object({ type: schema.string() }) }, + }, + createLicensedRouteHandler(async (_context, request, response) => { + const spacesClient = await spacesService.scopedClient(request); + + const { type } = request.query; + + try { + const shareToAllSpaces = await spacesClient.hasGlobalAllPrivilegesForObjectType(type); + return response.ok({ body: { shareToAllSpaces } }); + } catch (error) { + return response.customError(wrapError(error)); + } + }) + ); + externalRouter.post( { path: '/api/spaces/_share_saved_object_add', validate: { body: shareSchema } }, createLicensedRouteHandler(async (_context, request, response) => { From e68ecc43d2f34daae257fed6d19f90b83ad84e7d Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 30 Sep 2020 11:01:13 -0400 Subject: [PATCH 12/25] Change saved objects table to use force-delete This is necessary when deleting saved objects that exist in multiple namespaces. --- ...in-core-public.savedobjectsclient.delete.md | 2 +- ...na-plugin-core-public.savedobjectsclient.md | 2 +- src/core/public/public.api.md | 3 ++- .../saved_objects/saved_objects_client.ts | 18 ++++++++++++++++-- .../objects_table/saved_objects_table.tsx | 2 +- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.delete.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.delete.md index 3b5f5630e80600..3a5dcb51e2c42a 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.delete.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.delete.md @@ -9,5 +9,5 @@ Deletes an object Signature: ```typescript -delete: (type: string, id: string) => ReturnType; +delete: (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md index 904b9cce09d4e8..6e53b169b8bed3 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md @@ -23,7 +23,7 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkCreate](./kibana-plugin-core-public.savedobjectsclient.bulkcreate.md) | | (objects?: SavedObjectsBulkCreateObject[], options?: SavedObjectsBulkCreateOptions) => Promise<SavedObjectsBatchResponse<unknown>> | Creates multiple documents at once | | [bulkGet](./kibana-plugin-core-public.savedobjectsclient.bulkget.md) | | (objects?: Array<{
id: string;
type: string;
}>) => Promise<SavedObjectsBatchResponse<unknown>> | Returns an array of objects by id | | [create](./kibana-plugin-core-public.savedobjectsclient.create.md) | | <T = unknown>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>> | Persists an object | -| [delete](./kibana-plugin-core-public.savedobjectsclient.delete.md) | | (type: string, id: string) => ReturnType<SavedObjectsApi['delete']> | Deletes an object | +| [delete](./kibana-plugin-core-public.savedobjectsclient.delete.md) | | (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType<SavedObjectsApi['delete']> | Deletes an object | | [find](./kibana-plugin-core-public.savedobjectsclient.find.md) | | <T = unknown>(options: SavedObjectsFindOptions) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | | [get](./kibana-plugin-core-public.savedobjectsclient.get.md) | | <T = unknown>(type: string, id: string) => Promise<SimpleSavedObject<T>> | Fetches a single object | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 5970c9a8571c45..c681d9bd758ad7 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1029,8 +1029,9 @@ export class SavedObjectsClient { }>) => Promise>; bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; create: (type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise>; + // Warning: (ae-forgotten-export) The symbol "SavedObjectsDeleteOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "SavedObjectsClientContract" needs to be exported by the entry point index.d.ts - delete: (type: string, id: string) => ReturnType; + delete: (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType; // Warning: (ae-forgotten-export) The symbol "SavedObjectsFindOptions" needs to be exported by the entry point index.d.ts find: (options: SavedObjectsFindOptions_2) => Promise>; get: (type: string, id: string) => Promise>; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 6a10eb44d9ca49..beed3e6fe0a18f 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -96,6 +96,12 @@ export interface SavedObjectsBatchResponse { savedObjects: Array>; } +/** @public */ +export interface SavedObjectsDeleteOptions { + /** Force deletion of an object that exists in multiple namespaces */ + force?: boolean; +} + /** * Return type of the Saved Objects `find()` method. * @@ -261,12 +267,20 @@ export class SavedObjectsClient { * @param id * @returns */ - public delete = (type: string, id: string): ReturnType => { + public delete = ( + type: string, + id: string, + options?: SavedObjectsDeleteOptions + ): ReturnType => { if (!type || !id) { return Promise.reject(new Error('requires type and id')); } - return this.savedObjectsFetch(this.getPath([type, id]), { method: 'DELETE' }); + const query = { + force: !!options?.force, + }; + + return this.savedObjectsFetch(this.getPath([type, id]), { method: 'DELETE', query }); }; /** 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 d879a71cc22699..5011c0299abe8d 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 @@ -468,7 +468,7 @@ export class SavedObjectsTable extends Component - savedObjectsClient.delete(object.type, object.id) + savedObjectsClient.delete(object.type, object.id, { force: true }) ); await Promise.all(deletes); From f855fcde1ca9fe37f4654e52b0c00cc4db6ca788 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 30 Sep 2020 13:43:28 -0400 Subject: [PATCH 13/25] Fix "Copy to space" functional test Could not figure out why this broke, I did not change this flyout. At any rate, clicking a different part of the radio button fixed it. --- .../page_objects/copy_saved_objects_to_space_page.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts b/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts index 6b8680271635be..00a364bb7543ef 100644 --- a/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts +++ b/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts @@ -37,9 +37,9 @@ export function CopySavedObjectsToSpacePageProvider({ if (!overwrite) { const radio = await testSubjects.find('cts-copyModeControl-overwriteRadioGroup'); // a radio button consists of a div tag that contains an input, a div, and a label - // we can't click the input directly, need to go up one level and click the parent div - const div = await radio.findByXpath("//div[input[@id='overwriteDisabled']]"); - await div.click(); + // we can't click the input directly, need to click the label + const label = await radio.findByCssSelector('label[for="overwriteDisabled"]'); + await label.click(); } await testSubjects.click(`cts-space-selector-row-${destinationSpaceId}`); }, From 1761d87784c33570c6aa29aaac4179fece7b6123 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 30 Sep 2020 14:53:35 -0400 Subject: [PATCH 14/25] Fix unit tests that broke due to e68ecc4 --- src/core/public/saved_objects/saved_objects_client.test.ts | 4 +++- .../objects_table/saved_objects_table.test.tsx | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/public/saved_objects/saved_objects_client.test.ts b/src/core/public/saved_objects/saved_objects_client.test.ts index 20824af38af0f6..fab651379ea6af 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -132,7 +132,9 @@ describe('SavedObjectsClient', () => { Object { "body": undefined, "method": "DELETE", - "query": undefined, + "query": Object { + "force": false, + }, }, ] `); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 1bc3dc80665202..0adf55ed6bebb0 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -544,11 +544,13 @@ describe('SavedObjectsTable', () => { expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith(mockSelectedSavedObjects); expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( mockSavedObjects[0].type, - mockSavedObjects[0].id + mockSavedObjects[0].id, + { force: true } ); expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( mockSavedObjects[1].type, - mockSavedObjects[1].id + mockSavedObjects[1].id, + { force: true } ); expect(component.state('selectedSavedObjects').length).toBe(0); }); From ebf7da0648f2bf87aa21f4fa550f3a526a70901c Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 1 Oct 2020 10:03:42 -0400 Subject: [PATCH 15/25] PR review feedback --- x-pack/plugins/spaces/public/plugin.tsx | 6 +++--- .../components/selectable_spaces_control.tsx | 4 ++-- .../components/share_to_space_flyout.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 1d86d0664407ae..2a08f412664567 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin, StartServicesAccessor } from 'src/core/public'; import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; import { ManagementStart, ManagementSetup } from 'src/plugins/management/public'; @@ -53,7 +53,7 @@ export class SpacesPlugin implements Plugin['getStartServices'], + getStartServices: core.getStartServices as StartServicesAccessor, spacesManager: this.spacesManager, securityLicense: plugins.security?.license, }); @@ -73,7 +73,7 @@ export class SpacesPlugin implements Plugin['getStartServices'], + getStartServices: core.getStartServices as StartServicesAccessor, }); const copySavedObjectsToSpaceService = new CopySavedObjectsToSpaceService(); copySavedObjectsToSpaceService.setup({ 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 9ba4a7c537a041..a8e20f81359797 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 @@ -84,7 +84,7 @@ export const SelectableSpacesControl = (props: Props) => { @@ -120,7 +120,7 @@ export const SelectableSpacesControl = (props: Props) => { ); const hiddenSpacesLabel = i18n.translate( 'xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel', - { defaultMessage: '({hiddenCount} hidden)', values: { hiddenCount } } + { defaultMessage: '+{hiddenCount} hidden', values: { hiddenCount } } ); const hiddenSpaces = hiddenCount ? {hiddenSpacesLabel} : null; return ( diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx index 929f33cdf021b0..ceba7e20977464 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -233,7 +233,7 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { } return ( - + From 48f0698ffdd921ac7986f95366a1b40f0cef899a Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 1 Oct 2020 10:05:57 -0400 Subject: [PATCH 16/25] Tweak "no spaces available" text Use "subdued" color and spacer to bring it more in line with other text. --- .../components/no_spaces_available.tsx | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx index 52fc0bb32f5a46..f4fcda0d451e74 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { EuiLink, EuiText } from '@elastic/eui'; +import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { ApplicationStart } from 'src/core/public'; @@ -21,24 +21,27 @@ export const NoSpacesAvailable = (props: Props) => { } return ( - - - -
- ), - }} - /> - + <> + + + + +
+ ), + }} + /> + + ); }; From ebea8e4c3e300fb3c53e1dec458e081c0a3ad3f7 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 1 Oct 2020 17:01:11 -0400 Subject: [PATCH 17/25] Changes for additional PR review feedback --- x-pack/plugins/security/common/constants.ts | 10 ++++ .../check_saved_objects_privileges.test.ts | 47 +++++++++++++++++++ .../check_saved_objects_privileges.ts | 41 +++++++++------- ...ecure_saved_objects_client_wrapper.test.ts | 2 +- .../secure_saved_objects_client_wrapper.ts | 20 +++++--- x-pack/plugins/spaces/common/constants.ts | 10 ++++ .../public/lib/documentation_links.test.ts | 25 ++++++++++ .../spaces/public/lib/documentation_links.ts | 19 ++++++++ x-pack/plugins/spaces/public/lib/index.ts | 9 ++++ .../components/no_spaces_available.tsx | 2 +- .../components/selectable_spaces_control.tsx | 10 ++-- .../components/share_mode_control.tsx | 3 +- .../components/share_to_space_flyout.test.tsx | 6 +++ .../components/share_to_space_flyout.tsx | 6 +-- .../share_saved_objects_to_space_column.tsx | 3 +- .../spaces_manager/spaces_manager.test.ts | 2 +- .../public/spaces_manager/spaces_manager.ts | 2 +- .../routes/api/external/share_to_space.ts | 2 +- .../common/lib/saved_object_test_utils.ts | 2 +- 19 files changed, 181 insertions(+), 40 deletions(-) create mode 100644 x-pack/plugins/spaces/public/lib/documentation_links.test.ts create mode 100644 x-pack/plugins/spaces/public/lib/documentation_links.ts create mode 100644 x-pack/plugins/spaces/public/lib/index.ts diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index 44b6601daa7ff9..a0d63c0a9dd6f3 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -4,6 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +/** + * The identifier in a saved object's `namespaces` array when it is shared globally to all spaces. + */ +export const ALL_SPACES_ID = '*'; + +/** + * The identifier in a saved object's `namespaces` array when it is shared to an unknown space (e.g., one that the end user is not authorized to see). + */ +export const UNKNOWN_SPACE = '?'; + export const GLOBAL_RESOURCE = '*'; export const APPLICATION_PREFIX = 'kibana-'; export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts index f287cc04280ac9..4a2426a9e8a40f 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts @@ -95,6 +95,38 @@ describe('#checkSavedObjectsPrivileges', () => { ]; expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, { kibana: actions }); }); + + test(`uses checkPrivileges.globally when checking for "all spaces" (*)`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.globally.mockReturnValue(expectedResult as any); + mockSpacesService = undefined; + const checkSavedObjectsPrivileges = createFactory(); + + const namespaces = [undefined, 'default', namespace1, namespace1, '*']; + const result = await checkSavedObjectsPrivileges(actions, namespaces); + + expect(result).toBe(expectedResult); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.globally).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: actions }); + }); + + test(`uses checkPrivileges.globally when Spaces is disabled`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.globally.mockReturnValue(expectedResult as any); + mockSpacesService = undefined; + const checkSavedObjectsPrivileges = createFactory(); + + const namespaces = [undefined, 'default', namespace1, namespace1, '*']; + const result = await checkSavedObjectsPrivileges(actions, namespaces); + + expect(result).toBe(expectedResult); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.globally).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: actions }); + }); }); describe('when checking a single namespace', () => { @@ -115,6 +147,21 @@ describe('#checkSavedObjectsPrivileges', () => { expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, { kibana: actions }); }); + test(`uses checkPrivileges.globally when checking for "all spaces" (*)`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.globally.mockReturnValue(expectedResult as any); + mockSpacesService = undefined; + const checkSavedObjectsPrivileges = createFactory(); + + const result = await checkSavedObjectsPrivileges(actions, '*'); + + expect(result).toBe(expectedResult); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.globally).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: actions }); + }); + test(`uses checkPrivileges.globally when Spaces is disabled`, async () => { const expectedResult = Symbol(); mockCheckPrivileges.globally.mockReturnValue(expectedResult as any); diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts index 7c0ca7dcaa3922..6b70e25eb448d3 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts @@ -5,6 +5,7 @@ */ import { KibanaRequest } from '../../../../../src/core/server'; +import { ALL_SPACES_ID } from '../../common/constants'; import { SpacesService } from '../plugin'; import { CheckPrivilegesWithRequest, CheckPrivilegesResponse } from './types'; @@ -33,24 +34,32 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = ( namespaceOrNamespaces?: string | Array ) { const spacesService = getSpacesService(); - if (!spacesService) { - // Spaces disabled, authorizing globally - return await checkPrivilegesWithRequest(request).globally({ kibana: actions }); - } else if (Array.isArray(namespaceOrNamespaces)) { - // Spaces enabled, authorizing against multiple spaces - if (!namespaceOrNamespaces.length) { - throw new Error(`Can't check saved object privileges for 0 namespaces`); + const privileges = { kibana: actions }; + + if (spacesService) { + if (Array.isArray(namespaceOrNamespaces)) { + // Spaces enabled, authorizing against multiple spaces + if (!namespaceOrNamespaces.length) { + throw new Error(`Can't check saved object privileges for 0 namespaces`); + } + const spaceIds = uniq( + namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x)) + ); + + if (!spaceIds.includes(ALL_SPACES_ID)) { + return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, privileges); + } + } else { + // Spaces enabled, authorizing against a single space + const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces); + if (spaceId !== ALL_SPACES_ID) { + return await checkPrivilegesWithRequest(request).atSpace(spaceId, privileges); + } } - const spaceIds = uniq( - namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x)) - ); - - return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, { kibana: actions }); - } else { - // Spaces enabled, authorizing against a single space - const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces); - return await checkPrivilegesWithRequest(request).atSpace(spaceId, { kibana: actions }); } + + // Spaces plugin is disabled OR we are checking privileges for "all spaces", authorizing globally + return await checkPrivilegesWithRequest(request).globally(privileges); }; }; }; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index d58413ec5c2717..ecf95a142a769f 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -172,7 +172,7 @@ const expectObjectNamespaceFiltering = async ( ); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( 'login:', - namespaces + namespaces.filter((x) => x !== '*') // when we check what namespaces to redact, we don't check privileges for '*', only actual space IDs ); }; 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 95da13a7228d69..34c8a9d2df7836 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 @@ -18,6 +18,7 @@ import { SavedObjectsDeleteFromNamespacesOptions, SavedObjectsUtils, } from '../../../../../src/core/server'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; import { SecurityAuditLogger } from '../audit'; import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; import { CheckPrivilegesResponse } from '../authorization/types'; @@ -55,8 +56,6 @@ interface EnsureAuthorizedTypeResult { isGloballyAuthorized?: boolean; } -const ALL_SPACES_ID = '*'; - export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { private readonly actions: Actions; private readonly auditLogger: PublicMethodsOf; @@ -391,7 +390,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra private redactAndSortNamespaces(spaceIds: string[], privilegeMap: Record) { return spaceIds - .map((x) => (x === ALL_SPACES_ID || privilegeMap[x] ? x : '?')) + .map((x) => (x === ALL_SPACES_ID || privilegeMap[x] ? x : UNKNOWN_SPACE)) .sort(namespaceComparator); } @@ -406,7 +405,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return savedObject; } - const privilegeMap = await this.getNamespacesPrivilegeMap(savedObject.namespaces); + const namespaces = savedObject.namespaces.filter((x) => x !== ALL_SPACES_ID); // all users can see the "all spaces" ID + if (namespaces.length === 0) { + return savedObject; + } + + const privilegeMap = await this.getNamespacesPrivilegeMap(namespaces); return { ...savedObject, @@ -421,7 +425,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return response; } const { saved_objects: savedObjects } = response; - const namespaces = uniq(savedObjects.flatMap((savedObject) => savedObject.namespaces || [])); + const namespaces = uniq( + savedObjects.flatMap((savedObject) => savedObject.namespaces || []) + ).filter((x) => x !== ALL_SPACES_ID); // all users can see the "all spaces" ID if (namespaces.length === 0) { return response; } @@ -454,9 +460,9 @@ function uniq(arr: T[]): T[] { function namespaceComparator(a: string, b: string) { const A = a.toUpperCase(); const B = b.toUpperCase(); - if (A === '?' && B !== '?') { + if (A === UNKNOWN_SPACE && B !== UNKNOWN_SPACE) { return 1; - } else if (A !== '?' && B === '?') { + } else if (A !== UNKNOWN_SPACE && B === UNKNOWN_SPACE) { return -1; } return A > B ? 1 : A < B ? -1 : 0; diff --git a/x-pack/plugins/spaces/common/constants.ts b/x-pack/plugins/spaces/common/constants.ts index 33f1aae70ea009..bd47fe7b8b8777 100644 --- a/x-pack/plugins/spaces/common/constants.ts +++ b/x-pack/plugins/spaces/common/constants.ts @@ -6,6 +6,16 @@ export const DEFAULT_SPACE_ID = `default`; +/** + * The identifier in a saved object's `namespaces` array when it is shared globally to all spaces. + */ +export const ALL_SPACES_ID = '*'; + +/** + * The identifier in a saved object's `namespaces` array when it is shared to an unknown space (e.g., one that the end user is not authorized to see). + */ +export const UNKNOWN_SPACE = '?'; + /** * The minimum number of spaces required to show a search control. */ diff --git a/x-pack/plugins/spaces/public/lib/documentation_links.test.ts b/x-pack/plugins/spaces/public/lib/documentation_links.test.ts new file mode 100644 index 00000000000000..55319530304050 --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/documentation_links.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { docLinksServiceMock } from '../../../../../src/core/public/mocks'; +import { DocumentationLinksService } from './documentation_links'; + +describe('DocumentationLinksService', () => { + const setup = () => { + const docLinks = docLinksServiceMock.createStartContract(); + const service = new DocumentationLinksService(docLinks); + return { docLinks, service }; + }; + + describe('#getKibanaPrivilegesDocUrl', () => { + it('returns expected value', () => { + const { service } = setup(); + expect(service.getKibanaPrivilegesDocUrl()).toMatchInlineSnapshot( + `"https://www.elastic.co/guide/en/kibana/mocked-test-branch/kibana-privileges.html"` + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/lib/documentation_links.ts b/x-pack/plugins/spaces/public/lib/documentation_links.ts new file mode 100644 index 00000000000000..71ba89d5b87e29 --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/documentation_links.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DocLinksStart } from 'src/core/public'; + +export class DocumentationLinksService { + private readonly kbn: string; + + constructor(docLinks: DocLinksStart) { + this.kbn = `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/`; + } + + public getKibanaPrivilegesDocUrl() { + return `${this.kbn}kibana-privileges.html`; + } +} diff --git a/x-pack/plugins/spaces/public/lib/index.ts b/x-pack/plugins/spaces/public/lib/index.ts new file mode 100644 index 00000000000000..6dce93b81c7fc6 --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DocumentationLinksService } from './documentation_links'; + +export { DocumentationLinksService }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx index f4fcda0d451e74..afa65cc7ad7dfa 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx @@ -15,7 +15,7 @@ interface Props { export const NoSpacesAvailable = (props: Props) => { const { capabilities, getUrlForApp } = props.application; - const canCreateNewSpaces = capabilities.spaces?.manage; + const canCreateNewSpaces = capabilities.spaces.manage; if (!canCreateNewSpaces) { 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 a8e20f81359797..3cd7093b0bb209 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 @@ -21,6 +21,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { CoreStart } from 'src/core/public'; import { NoSpacesAvailable } from './no_spaces_available'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; +import { DocumentationLinksService } from '../../lib'; import { SpaceAvatar } from '../../space_avatar'; import { SpaceTarget } from '../types'; @@ -33,8 +35,6 @@ interface Props { type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; -const ALL_SPACES_ID = '*'; -const UNKNOWN_SPACE = '?'; const ROW_HEIGHT = 40; const activeSpaceProps = { append: Current, @@ -77,7 +77,9 @@ export const SelectableSpacesControl = (props: Props) => { return null; } - const kibanaPrivilegesUrl = `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/kibana-privileges.html`; + const kibanaPrivilegesUrl = new DocumentationLinksService( + docLinks! + ).getKibanaPrivilegesDocUrl(); return ( <> @@ -102,7 +104,7 @@ export const SelectableSpacesControl = (props: Props) => { }; const getNoSpacesAvailable = () => { if (spaces.length < 2) { - return ; + return ; } return null; }; 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 4ad37094b18af1..2f1a3e0d459eeb 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 @@ -20,6 +20,7 @@ import { import { i18n } from '@kbn/i18n'; import { CoreStart } from 'src/core/public'; import { SelectableSpacesControl } from './selectable_spaces_control'; +import { ALL_SPACES_ID } from '../../../common/constants'; import { SpaceTarget } from '../types'; interface Props { @@ -31,8 +32,6 @@ interface Props { disabled?: boolean; } -const ALL_SPACES_ID = '*'; - function createLabel({ title, text, diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx index 13826a519b1e84..ad49161ddd7053 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx @@ -86,6 +86,12 @@ const setup = async (opts: SetupOpts = {}) => { } as SavedObjectsManagementRecord; const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + startServices.application.capabilities = { + ...startServices.application.capabilities, + spaces: { manage: true }, + }; + getStartServices.mockResolvedValue([startServices, , ,]); const wrapper = mountWithIntl( ; } -const ALL_SPACES_ID = '*'; const arraysAreEqual = (a: unknown[], b: unknown[]) => a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); @@ -98,10 +98,10 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; } const initialSelection = currentNamespaces.filter( - (spaceId) => spaceId !== activeSpace.id && spaceId !== '?' + (spaceId) => spaceId !== activeSpace.id && spaceId !== UNKNOWN_SPACE ); const { selectedSpaceIds } = shareOptions; - const filteredSelection = selectedSpaceIds.filter((x) => x !== '?'); + const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); const isSharedToAllSpaces = !initialSelection.includes(ALL_SPACES_ID) && filteredSelection.includes(ALL_SPACES_ID); const isUnsharedFromAllSpaces = diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx index b34287a3c5c449..0f988cf5a152db 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx @@ -13,11 +13,10 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { SavedObjectsManagementColumn } from '../../../../../src/plugins/saved_objects_management/public'; import { SpaceTarget } from './types'; import { SpacesManager } from '../spaces_manager'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; import { getSpaceColor } from '..'; const SPACES_DISPLAY_COUNT = 5; -const ALL_SPACES_ID = '*'; -const UNKNOWN_SPACE = '?'; type SpaceMap = Map; interface ColumnDataProps { 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 06cf3ef17dc820..7f005e37d96e90 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 @@ -116,7 +116,7 @@ describe('SpacesManager', () => { const result = await spacesManager.getShareSavedObjectPermissions('foo'); expect(coreStart.http.get).toHaveBeenCalledTimes(2); expect(coreStart.http.get).toHaveBeenLastCalledWith( - '/api/spaces/_share_saved_object_permissions', + '/internal/spaces/_share_saved_object_permissions', { query: { type: 'foo' }, } 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 98b00c58bf27d4..c81f7c17b7770d 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -109,7 +109,7 @@ export class SpacesManager { public async getShareSavedObjectPermissions( type: string ): Promise<{ shareToAllSpaces: boolean }> { - return this.http.get('/api/spaces/_share_saved_object_permissions', { query: { type } }); + return this.http.get('/internal/spaces/_share_saved_object_permissions', { query: { type } }); } public async shareSavedObjectAdd(object: SavedObject, spaces: string[]): Promise { diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts index cc3573896ca8ff..5bf3b2779ba2a2 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts @@ -39,7 +39,7 @@ export function initShareToSpacesApi(deps: ExternalRouteDeps) { externalRouter.get( { - path: '/api/spaces/_share_saved_object_permissions', + path: '/internal/spaces/_share_saved_object_permissions', validate: { query: schema.object({ type: schema.string() }) }, }, createLicensedRouteHandler(async (_context, request, response) => { diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index 395a343a2af1ee..511d183145a305 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -74,7 +74,7 @@ export const getTestTitle = ( export const isUserAuthorizedAtSpace = (user: TestUser | undefined, namespace: string) => !user || - namespace === ALL_SPACES_ID || + (user.authorizedAtSpaces.length > 0 && namespace === ALL_SPACES_ID) || user.authorizedAtSpaces.includes('*') || user.authorizedAtSpaces.includes(namespace); From bc9a57b40ac595019689259198d970fd98152a95 Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 1 Oct 2020 18:52:48 -0400 Subject: [PATCH 18/25] Address nits with SSOTAS authz --- .../feature_privilege_builder/saved_object.ts | 9 +- .../privileges/privileges.test.ts | 35 ++++++ .../lib/spaces_client/spaces_client.test.ts | 119 ------------------ .../server/lib/spaces_client/spaces_client.ts | 21 ---- x-pack/plugins/spaces/server/plugin.ts | 1 + .../server/routes/api/external/index.ts | 2 + .../api/external/share_to_space.test.ts | 76 ++++++++++- .../routes/api/external/share_to_space.ts | 22 ++-- 8 files changed, 134 insertions(+), 151 deletions(-) diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts index 0dd89f2c5f3c1d..6da0b93e1461fc 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts @@ -9,7 +9,14 @@ import { FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; const readOperations: string[] = ['bulk_get', 'get', 'find']; -const writeOperations: string[] = ['create', 'bulk_create', 'update', 'bulk_update', 'delete']; +const writeOperations: string[] = [ + 'create', + 'bulk_create', + 'update', + 'bulk_update', + 'delete', + 'share_to_space', +]; const allOperations: string[] = [...readOperations, ...writeOperations]; export class FeaturePrivilegeSavedObjectBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 6f721c91fbd67a..2b1268b11a0ffa 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -106,6 +106,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-1', 'update'), actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), actions.savedObject.get('all-savedObject-all-1', 'delete'), + actions.savedObject.get('all-savedObject-all-1', 'share_to_space'), actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), actions.savedObject.get('all-savedObject-all-2', 'get'), actions.savedObject.get('all-savedObject-all-2', 'find'), @@ -114,6 +115,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-2', 'update'), actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), actions.savedObject.get('all-savedObject-all-2', 'delete'), + actions.savedObject.get('all-savedObject-all-2', 'share_to_space'), actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), actions.savedObject.get('all-savedObject-read-1', 'get'), actions.savedObject.get('all-savedObject-read-1', 'find'), @@ -135,6 +137,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'update'), actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-1', 'share_to_space'), actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), @@ -143,6 +146,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'update'), actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'share_to_space'), actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), @@ -276,6 +280,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-1', 'update'), actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), actions.savedObject.get('all-savedObject-all-1', 'delete'), + actions.savedObject.get('all-savedObject-all-1', 'share_to_space'), actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), actions.savedObject.get('all-savedObject-all-2', 'get'), actions.savedObject.get('all-savedObject-all-2', 'find'), @@ -284,6 +289,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-2', 'update'), actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), actions.savedObject.get('all-savedObject-all-2', 'delete'), + actions.savedObject.get('all-savedObject-all-2', 'share_to_space'), actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), actions.savedObject.get('all-savedObject-read-1', 'get'), actions.savedObject.get('all-savedObject-read-1', 'find'), @@ -304,6 +310,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'update'), actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-1', 'share_to_space'), actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), @@ -312,6 +319,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'update'), actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'share_to_space'), actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), @@ -387,6 +395,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'update'), actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-1', 'share_to_space'), actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), @@ -395,6 +404,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'update'), actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'share_to_space'), actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), @@ -692,6 +702,7 @@ describe('reserved', () => { actions.savedObject.get('savedObject-all-1', 'update'), actions.savedObject.get('savedObject-all-1', 'bulk_update'), actions.savedObject.get('savedObject-all-1', 'delete'), + actions.savedObject.get('savedObject-all-1', 'share_to_space'), actions.savedObject.get('savedObject-all-2', 'bulk_get'), actions.savedObject.get('savedObject-all-2', 'get'), actions.savedObject.get('savedObject-all-2', 'find'), @@ -700,6 +711,7 @@ describe('reserved', () => { actions.savedObject.get('savedObject-all-2', 'update'), actions.savedObject.get('savedObject-all-2', 'bulk_update'), actions.savedObject.get('savedObject-all-2', 'delete'), + actions.savedObject.get('savedObject-all-2', 'share_to_space'), actions.savedObject.get('savedObject-read-1', 'bulk_get'), actions.savedObject.get('savedObject-read-1', 'get'), actions.savedObject.get('savedObject-read-1', 'find'), @@ -822,6 +834,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -950,6 +963,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -967,6 +981,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -991,6 +1006,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1021,6 +1037,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1038,6 +1055,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1056,6 +1074,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1073,6 +1092,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1151,6 +1171,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1168,6 +1189,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1192,6 +1214,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1292,6 +1315,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1309,6 +1333,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1351,6 +1376,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1374,6 +1400,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1457,6 +1484,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1474,6 +1502,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1588,6 +1617,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1608,6 +1638,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1634,6 +1665,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1651,6 +1683,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1669,6 +1702,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1686,6 +1720,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 397ef6e20dfa8a..4502b081034aa4 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -1323,122 +1323,3 @@ describe('#delete', () => { }); }); }); - -describe('#hasGlobalAllPrivilegesForObjectType', () => { - const type = 'foo'; - - describe(`authorization is null`, () => { - test(`returns true`, async () => { - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const authorization = null; - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - authorization, - null, - mockConfig, - null, - request - ); - - const result = await client.hasGlobalAllPrivilegesForObjectType(type); - expect(result).toEqual(true); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); - }); - }); - - describe(`authorization.mode.useRbacForRequest is false`, () => { - test(`returns true`, async () => { - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const { mockAuthorization } = createMockAuthorization(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - mockAuthorization, - null, - mockConfig, - null, - request - ); - - const result = await client.hasGlobalAllPrivilegesForObjectType(type); - expect(result).toEqual(true); - }); - }); - - describe('useRbacForRequest is true', () => { - test(`returns false if user is not authorized to enumerate spaces`, async () => { - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesGlobally.mockReturnValue({ hasAllRequested: false }); - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - mockAuthorization, - null, - mockConfig, - null, - request - ); - - const result = await client.hasGlobalAllPrivilegesForObjectType(type); - expect(result).toEqual(false); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: [ - mockAuthorization.actions.savedObject.get(type, 'create'), - mockAuthorization.actions.savedObject.get(type, 'get'), - mockAuthorization.actions.savedObject.get(type, 'update'), - mockAuthorization.actions.savedObject.get(type, 'delete'), - ], - }); - }); - - test(`returns true if user is authorized to enumerate spaces`, async () => { - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesGlobally.mockReturnValue({ hasAllRequested: true }); - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - mockAuthorization, - null, - mockConfig, - null, - request - ); - - const result = await client.hasGlobalAllPrivilegesForObjectType(type); - expect(result).toEqual(true); - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: [ - mockAuthorization.actions.savedObject.get(type, 'create'), - mockAuthorization.actions.savedObject.get(type, 'get'), - mockAuthorization.actions.savedObject.get(type, 'update'), - mockAuthorization.actions.savedObject.get(type, 'delete'), - ], - }); - }); - }); -}); diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index e0fdc64285d5f1..50e7182b766863 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -215,27 +215,6 @@ export class SpacesClient { await repository.delete('space', id); } - public async hasGlobalAllPrivilegesForObjectType(type: string) { - if (this.useRbac()) { - const kibanaPrivileges = ['create', 'get', 'update', 'delete'].map((operation) => - this.authorization!.actions.savedObject.get(type, operation) - ); - const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { hasAllRequested } = await checkPrivileges.globally({ kibana: kibanaPrivileges }); - // we do not audit the outcome of this privilege check, because it is called automatically to determine UI capabilities - this.debugLogger( - `SpacesClient.hasGlobalAllPrivilegesForObjectType, using RBAC. Result: ${hasAllRequested}` - ); - return hasAllRequested; - } - - // If not RBAC, then security isn't enabled and we can enumerate all spaces - this.debugLogger( - `SpacesClient.hasGlobalAllPrivilegesForObjectType, NOT USING RBAC. Result: true` - ); - return true; - } - private useRbac(): boolean { return this.authorization != null && this.authorization.mode.useRbacForRequest(this.request); } diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index af54effcaeca69..a9ba5ac2dc6dea 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -107,6 +107,7 @@ export class Plugin { getStartServices: core.getStartServices, getImportExportObjectLimit: core.savedObjects.getImportExportObjectLimit, spacesService, + authorization: plugins.security ? plugins.security.authz : null, }); const internalRouter = core.http.createRouter(); 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 dd93cffd28dd70..f093f26b4bdee1 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts @@ -5,6 +5,7 @@ */ import { Logger, IRouter, CoreSetup } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../../../security/server'; import { initDeleteSpacesApi } from './delete'; import { initGetSpaceApi } from './get'; import { initGetAllSpacesApi } from './get_all'; @@ -20,6 +21,7 @@ export interface ExternalRouteDeps { getImportExportObjectLimit: () => number; spacesService: SpacesServiceSetup; log: Logger; + authorization: SecurityPluginSetup['authz'] | null; } export function initExternalSpacesApi(deps: ExternalRouteDeps) { diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts index e330cd7c660c28..3af1d9d245d10b 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts @@ -25,12 +25,15 @@ import { initShareToSpacesApi } from './share_to_space'; import { spacesConfig } from '../../../lib/__fixtures__'; import { securityMock } from '../../../../../security/server/mocks'; import { ObjectType } from '@kbn/config-schema'; +import { SecurityPluginSetup } from '../../../../../security/server'; describe('share to space', () => { const spacesSavedObjects = createSpaces(); const spaces = spacesSavedObjects.map((s) => ({ id: s.id, ...s.attributes })); - const setup = async () => { + const setup = async ({ + authorization = null, + }: { authorization?: SecurityPluginSetup['authz'] | null } = {}) => { const httpService = httpServiceMock.createSetupContract(); const router = httpService.createRouter(); const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); @@ -43,7 +46,7 @@ describe('share to space', () => { const spacesService = await service.setup({ http: (httpService as unknown) as CoreSetup['http'], getStartServices: async () => [coreStart, {}, {}], - authorization: securityMock.createSetup().authz, + authorization, auditLogger: {} as SpacesAuditLogger, config$: Rx.of(spacesConfig), }); @@ -68,6 +71,7 @@ describe('share to space', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization, }); const [ @@ -75,6 +79,8 @@ describe('share to space', () => { [shareRemove, resolveRouteHandler], ] = router.post.mock.calls; + const [[, permissionsRouteHandler]] = router.get.mock.calls; + return { coreStart, savedObjectsClient, @@ -86,10 +92,76 @@ describe('share to space', () => { routeValidation: shareRemove.validate as RouteValidatorConfig<{}, {}, {}>, routeHandler: resolveRouteHandler, }, + sharePermissions: { + routeHandler: permissionsRouteHandler, + }, savedObjectsRepositoryMock, }; }; + describe('GET /internal/spaces/_share_saved_object_permissions', () => { + it('returns true when security is not enabled', async () => { + const { sharePermissions } = await setup(); + + const request = httpServerMock.createKibanaRequest({ query: { type: 'foo' }, method: 'get' }); + const response = await sharePermissions.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status, payload } = response; + expect(status).toEqual(200); + expect(payload).toEqual({ shareToAllSpaces: true }); + }); + + it('returns false when the user is not authorized globally', async () => { + const authorization = securityMock.createSetup().authz; + const globalPrivilegesCheck = jest.fn().mockResolvedValue({ hasAllRequested: false }); + authorization.checkPrivilegesWithRequest.mockReturnValue({ + globally: globalPrivilegesCheck, + }); + const { sharePermissions } = await setup({ authorization }); + + const request = httpServerMock.createKibanaRequest({ query: { type: 'foo' }, method: 'get' }); + const response = await sharePermissions.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status, payload } = response; + expect(status).toEqual(200); + expect(payload).toEqual({ shareToAllSpaces: false }); + + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + }); + + it('returns true when the user is authorized globally', async () => { + const authorization = securityMock.createSetup().authz; + const globalPrivilegesCheck = jest.fn().mockResolvedValue({ hasAllRequested: true }); + authorization.checkPrivilegesWithRequest.mockReturnValue({ + globally: globalPrivilegesCheck, + }); + const { sharePermissions } = await setup({ authorization }); + + const request = httpServerMock.createKibanaRequest({ query: { type: 'foo' }, method: 'get' }); + const response = await sharePermissions.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status, payload } = response; + expect(status).toEqual(200); + expect(payload).toEqual({ shareToAllSpaces: true }); + + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + }); + }); + describe('POST /api/spaces/_share_saved_object_add', () => { const object = { id: 'foo', type: 'bar' }; diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts index 5bf3b2779ba2a2..06bce2684a36e0 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts @@ -13,7 +13,7 @@ import { ALL_SPACES_STRING } from '../../../lib/utils/namespace'; const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); export function initShareToSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, getStartServices, spacesService } = deps; + const { externalRouter, getStartServices, authorization } = deps; const shareSchema = schema.object({ spaces: schema.arrayOf( @@ -43,16 +43,22 @@ export function initShareToSpacesApi(deps: ExternalRouteDeps) { validate: { query: schema.object({ type: schema.string() }) }, }, createLicensedRouteHandler(async (_context, request, response) => { - const spacesClient = await spacesService.scopedClient(request); - + let shareToAllSpaces = true; const { type } = request.query; - try { - const shareToAllSpaces = await spacesClient.hasGlobalAllPrivilegesForObjectType(type); - return response.ok({ body: { shareToAllSpaces } }); - } catch (error) { - return response.customError(wrapError(error)); + if (authorization) { + try { + const checkPrivileges = authorization.checkPrivilegesWithRequest(request); + shareToAllSpaces = ( + await checkPrivileges.globally({ + kibana: authorization.actions.savedObject.get(type, 'share_to_space'), + }) + ).hasAllRequested; + } catch (error) { + return response.customError(wrapError(error)); + } } + return response.ok({ body: { shareToAllSpaces } }); }) ); From c55dca30cfecee7c8bcebfe04a60eef200c70e31 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 1 Oct 2020 19:03:42 -0400 Subject: [PATCH 19/25] Change `addToNamespaces` and `deleteFromNamespaces` authZ check --- ...ecure_saved_objects_client_wrapper.test.ts | 21 +++++++++---------- .../secure_saved_objects_client_wrapper.ts | 18 ++++++++-------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index ecf95a142a769f..af1aaf16f7fedb 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -283,8 +283,7 @@ describe('#addToNamespaces', () => { const newNs2 = 'bar-namespace'; const namespaces = [newNs1, newNs2]; const currentNs = 'default'; - const privilege1 = `mock-saved_object:${type}/create`; - const privilege2 = `mock-saved_object:${type}/update`; + const privilege = `mock-saved_object:${type}/share_to_space`; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.addToNamespaces, { type, id, namespaces }); @@ -306,7 +305,7 @@ describe('#addToNamespaces', () => { 'addToNamespacesCreate', [type], namespaces.sort(), - [{ privilege: privilege1, spaceId: newNs1 }], + [{ privilege, spaceId: newNs1 }], { id, type, namespaces, options: {} } ); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -333,7 +332,7 @@ describe('#addToNamespaces', () => { 'addToNamespacesUpdate', [type], [currentNs], - [{ privilege: privilege2, spaceId: currentNs }], + [{ privilege, spaceId: currentNs }], { id, type, namespaces, options: {} } ); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); @@ -351,7 +350,7 @@ describe('#addToNamespaces', () => { expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( 1, USERNAME, - 'addToNamespacesCreate', // action for privilege check is 'create', but auditAction is 'addToNamespacesCreate' + 'addToNamespacesCreate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesCreate' [type], namespaces.sort(), { type, id, namespaces, options: {} } @@ -359,7 +358,7 @@ describe('#addToNamespaces', () => { expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( 2, USERNAME, - 'addToNamespacesUpdate', // action for privilege check is 'update', but auditAction is 'addToNamespacesUpdate' + 'addToNamespacesUpdate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesUpdate' [type], [currentNs], { type, id, namespaces, options: {} } @@ -379,12 +378,12 @@ describe('#addToNamespaces', () => { expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( 1, - [privilege1], + [privilege], namespaces ); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( 2, - [privilege2], + [privilege], undefined // default namespace ); }); @@ -802,7 +801,7 @@ describe('#deleteFromNamespaces', () => { const namespace1 = 'foo-namespace'; const namespace2 = 'bar-namespace'; const namespaces = [namespace1, namespace2]; - const privilege = `mock-saved_object:${type}/delete`; + const privilege = `mock-saved_object:${type}/share_to_space`; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.deleteFromNamespaces, { type, id, namespaces }); @@ -821,7 +820,7 @@ describe('#deleteFromNamespaces', () => { expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( USERNAME, - 'deleteFromNamespaces', // action for privilege check is 'delete', but auditAction is 'deleteFromNamespaces' + 'deleteFromNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'deleteFromNamespaces' [type], namespaces.sort(), [{ privilege, spaceId: namespace1 }], @@ -841,7 +840,7 @@ describe('#deleteFromNamespaces', () => { expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( USERNAME, - 'deleteFromNamespaces', // action for privilege check is 'delete', but auditAction is 'deleteFromNamespaces' + 'deleteFromNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'deleteFromNamespaces' [type], namespaces.sort(), { type, id, namespaces, options: {} } 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 34c8a9d2df7836..d94dac942845ec 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 @@ -213,17 +213,17 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { const args = { type, id, namespaces, options }; const { namespace } = options; - // To share an object, the user must have the "create" permission in each of the destination namespaces. - await this.ensureAuthorized(type, 'create', namespaces, { + // To share an object, the user must have the "share_to_space" permission in each of the destination namespaces. + await this.ensureAuthorized(type, 'share_to_space', namespaces, { args, auditAction: 'addToNamespacesCreate', }); - // To share an object, the user must also have the "update" permission in one or more of the source namespaces. Because the - // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "update" permission in the - // current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation will - // result in a 404 error. - await this.ensureAuthorized(type, 'update', namespace, { + // To share an object, the user must also have the "share_to_space" permission in one or more of the source namespaces. Because the + // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "share_to_space" permission in + // the current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation + // will result in a 404 error. + await this.ensureAuthorized(type, 'share_to_space', namespace, { args, auditAction: 'addToNamespacesUpdate', }); @@ -239,8 +239,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsDeleteFromNamespacesOptions = {} ) { const args = { type, id, namespaces, options }; - // To un-share an object, the user must have the "delete" permission in each of the target namespaces. - await this.ensureAuthorized(type, 'delete', namespaces, { + // To un-share an object, the user must have the "share_to_space" permission in each of the target namespaces. + await this.ensureAuthorized(type, 'share_to_space', namespaces, { args, auditAction: 'deleteFromNamespaces', }); From 64a5b8403ffb13d1ab6b5accc33700b8fff69dae Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 1 Oct 2020 19:04:45 -0400 Subject: [PATCH 20/25] Don't pass start services to all share-to-space components --- .../components/selectable_spaces_control.tsx | 8 +++--- .../components/share_mode_control.tsx | 2 -- .../components/share_to_space_flyout.tsx | 25 ++++++++++++------- .../components/share_to_space_form.tsx | 13 +--------- 4 files changed, 21 insertions(+), 27 deletions(-) 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 3cd7093b0bb209..29303a478daeb9 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 @@ -19,15 +19,14 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { CoreStart } from 'src/core/public'; import { NoSpacesAvailable } from './no_spaces_available'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { DocumentationLinksService } from '../../lib'; import { SpaceAvatar } from '../../space_avatar'; import { SpaceTarget } from '../types'; interface Props { - coreStart: CoreStart; spaces: SpaceTarget[]; selectedSpaceIds: string[]; onChange: (selectedSpaceIds: string[]) => void; @@ -43,8 +42,9 @@ const activeSpaceProps = { }; export const SelectableSpacesControl = (props: Props) => { - const { coreStart, spaces, selectedSpaceIds, onChange } = props; - const { application, docLinks } = coreStart; + const { spaces, selectedSpaceIds, onChange } = props; + const { services } = useKibana(); + const { application, docLinks } = services; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); const options = spaces 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 2f1a3e0d459eeb..dca52e6e529a16 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 @@ -18,13 +18,11 @@ import { EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { ALL_SPACES_ID } from '../../../common/constants'; import { SpaceTarget } from '../types'; interface Props { - coreStart: CoreStart; spaces: SpaceTarget[]; canShareToAllSpaces: boolean; selectedSpaceIds: string[]; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx index b2729c504ce232..6461a3299f09bf 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -23,6 +23,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ToastsStart, StartServicesAccessor, CoreStart } from 'src/core/public'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { Space } from '../../../common/model/space'; import { SpacesManager } from '../../spaces_manager'; @@ -207,17 +208,23 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { const activeSpace = spaces.find((x) => x.isActiveSpace)!; const showShareWarning = spaces.length > 1 && arraysAreEqual(currentNamespaces, [activeSpace.id]); + const { application, docLinks } = coreStart!; + const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + application, + docLinks, + }); // Step 2: Share has not been initiated yet; User must fill out form to continue. return ( - setShowMakeCopy(true)} - /> + + setShowMakeCopy(true)} + /> + ); }; 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 0aa545f79d99ee..bc196208ab35c9 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 @@ -8,12 +8,10 @@ import './share_to_space_form.scss'; import React, { Fragment } from 'react'; import { EuiHorizontalRule, EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { CoreStart } from 'src/core/public'; import { ShareOptions, SpaceTarget } from '../types'; import { ShareModeControl } from './share_mode_control'; interface Props { - coreStart: CoreStart; spaces: SpaceTarget[]; onUpdate: (shareOptions: ShareOptions) => void; shareOptions: ShareOptions; @@ -23,15 +21,7 @@ interface Props { } export const ShareToSpaceForm = (props: Props) => { - const { - coreStart, - spaces, - onUpdate, - shareOptions, - showShareWarning, - canShareToAllSpaces, - makeCopy, - } = props; + const { spaces, onUpdate, shareOptions, showShareWarning, canShareToAllSpaces, makeCopy } = props; const setSelectedSpaceIds = (selectedSpaceIds: string[]) => onUpdate({ ...shareOptions, selectedSpaceIds }); @@ -79,7 +69,6 @@ export const ShareToSpaceForm = (props: Props) => { {getShareWarning()} Date: Thu, 1 Oct 2020 20:19:48 -0400 Subject: [PATCH 21/25] Fix type check --- .../spaces/server/routes/api/external/copy_to_space.test.ts | 1 + x-pack/plugins/spaces/server/routes/api/external/delete.test.ts | 1 + x-pack/plugins/spaces/server/routes/api/external/get.test.ts | 1 + x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts | 1 + x-pack/plugins/spaces/server/routes/api/external/post.test.ts | 1 + x-pack/plugins/spaces/server/routes/api/external/put.test.ts | 1 + 6 files changed, 6 insertions(+) diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index bc1e4c3fe4a447..341e5cf3bfbe0f 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -103,6 +103,7 @@ describe('copy to space', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); const [ diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts index 5461aaf1e36ea8..4fe81027c35085 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -73,6 +73,7 @@ describe('Spaces Public API', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); const [routeDefinition, routeHandler] = router.delete.mock.calls[0]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts index ac9a46ee9c3fac..47863999366624 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts @@ -67,6 +67,7 @@ describe('GET space', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); return { diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index a9b701a8ea395e..deb8434497ae7c 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -67,6 +67,7 @@ describe('GET /spaces/space', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); return { diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts index 6aa89b36b020ab..6aeec251e33e48 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -67,6 +67,7 @@ describe('Spaces Public API', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); const [routeDefinition, routeHandler] = router.post.mock.calls[0]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts index ebdffa20a6c8e9..326837f8995f0b 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -68,6 +68,7 @@ describe('PUT /api/spaces/space', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); const [routeDefinition, routeHandler] = router.put.mock.calls[0]; From 08eaf51551f81daf954acaa72a9d7b742b71f640 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 2 Oct 2020 01:43:57 -0400 Subject: [PATCH 22/25] Fix API integration tests --- .../common/suites/share_add.ts | 9 ++++----- .../common/suites/share_remove.ts | 2 +- .../security_and_spaces/apis/share_add.ts | 16 ++++++++-------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/x-pack/test/spaces_api_integration/common/suites/share_add.ts b/x-pack/test/spaces_api_integration/common/suites/share_add.ts index 54d636c938b580..f2a3c69a91196a 100644 --- a/x-pack/test/spaces_api_integration/common/suites/share_add.ts +++ b/x-pack/test/spaces_api_integration/common/suites/share_add.ts @@ -26,7 +26,6 @@ export interface ShareAddTestCase { id: string; namespaces: string[]; failure?: 400 | 403 | 404; - fail403Param?: string; } const TYPE = 'sharedtype'; @@ -38,13 +37,14 @@ const getTestTitle = ({ id, namespaces }: ShareAddTestCase) => `{id: ${id}, namespaces: [${namespaces.join(',')}]}`; export function shareAddTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectForbidden = expectResponses.forbiddenTypes('share_to_space'); const expectResponseBody = (testCase: ShareAddTestCase): ExpectResponseBody => async ( response: Record ) => { - const { id, failure, fail403Param } = testCase; + const { id, failure } = testCase; const object = response.body; if (failure === 403) { - await expectResponses.forbiddenTypes(fail403Param!)(TYPE)(response); + await expectForbidden(TYPE)(response); } else if (failure === 404) { const error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); expect(object.error).to.eql(error.output.payload.error); @@ -59,13 +59,12 @@ export function shareAddTestSuiteFactory(esArchiver: any, supertest: SuperTest
{ let cases = Array.isArray(testCases) ? testCases : [testCases]; if (forbidden) { // override the expected result in each test case - cases = cases.map((x) => ({ ...x, failure: 403, fail403Param: options?.fail403Param })); + cases = cases.map((x) => ({ ...x, failure: 403 })); } return cases.map((x) => ({ title: getTestTitle(x), diff --git a/x-pack/test/spaces_api_integration/common/suites/share_remove.ts b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts index 0169d4eb4c64bc..d318609e115701 100644 --- a/x-pack/test/spaces_api_integration/common/suites/share_remove.ts +++ b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts @@ -36,7 +36,7 @@ const createRequest = ({ id, namespaces }: ShareRemoveTestCase) => ({ }); export function shareRemoveTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbiddenTypes('delete'); + const expectForbidden = expectResponses.forbiddenTypes('share_to_space'); const expectResponseBody = (testCase: ShareRemoveTestCase): ExpectResponseBody => async ( response: Record ) => { diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts index 937aaff0595801..40f87cb90933f3 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts @@ -94,20 +94,20 @@ export default function ({ getService }: TestInvoker) { const otherSpaceId = spaceId === DEFAULT_SPACE_ID ? SPACE_1_ID : DEFAULT_SPACE_ID; const otherSpace = calculateSingleSpaceAuthZ(testCases, otherSpaceId); return { - unauthorized: createTestDefinitions(testCases, true, { fail403Param: 'create' }), + unauthorized: createTestDefinitions(testCases, true), authorizedInSpace: [ - createTestDefinitions(thisSpace.targetsAllSpaces, true, { fail403Param: 'create' }), - createTestDefinitions(thisSpace.targetsOtherSpace, true, { fail403Param: 'create' }), + createTestDefinitions(thisSpace.targetsAllSpaces, true), + createTestDefinitions(thisSpace.targetsOtherSpace, true), createTestDefinitions(thisSpace.doesntExistInThisSpace, false), createTestDefinitions(thisSpace.existsInThisSpace, false), ].flat(), authorizedInOtherSpace: [ - createTestDefinitions(thisSpace.targetsAllSpaces, true, { fail403Param: 'create' }), - createTestDefinitions(otherSpace.targetsOtherSpace, true, { fail403Param: 'create' }), - // If the preflight GET request fails, it will return a 404 error; users who are authorized to create saved objects in the target - // space(s) but are not authorized to update saved objects in this space will see a 403 error instead of 404. This is a safeguard to + createTestDefinitions(thisSpace.targetsAllSpaces, true), + createTestDefinitions(otherSpace.targetsOtherSpace, true), + // If the preflight GET request fails, it will return a 404 error; users who are authorized to share saved objects in the target + // space(s) but are not authorized to share saved objects in this space will see a 403 error instead of 404. This is a safeguard to // prevent potential information disclosure of the spaces that a given saved object may exist in. - createTestDefinitions(otherSpace.doesntExistInThisSpace, true, { fail403Param: 'update' }), + createTestDefinitions(otherSpace.doesntExistInThisSpace, true), createTestDefinitions(otherSpace.existsInThisSpace, false), ].flat(), authorized: createTestDefinitions(testCases, false), From e0ad3628dc3f841051041d77e0fae2f7017c0f48 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 2 Oct 2020 15:10:47 -0400 Subject: [PATCH 23/25] More changes for PR review feedback --- x-pack/plugins/spaces/public/lib/index.ts | 4 +--- x-pack/plugins/spaces/server/lib/utils/namespace.ts | 2 -- .../spaces/server/routes/api/external/share_to_space.ts | 4 ++-- .../server/saved_objects/spaces_saved_objects_client.ts | 5 +++-- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/spaces/public/lib/index.ts b/x-pack/plugins/spaces/public/lib/index.ts index 6dce93b81c7fc6..87b54dd4e2ef39 100644 --- a/x-pack/plugins/spaces/public/lib/index.ts +++ b/x-pack/plugins/spaces/public/lib/index.ts @@ -4,6 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DocumentationLinksService } from './documentation_links'; - -export { DocumentationLinksService }; +export { DocumentationLinksService } from './documentation_links'; diff --git a/x-pack/plugins/spaces/server/lib/utils/namespace.ts b/x-pack/plugins/spaces/server/lib/utils/namespace.ts index a34796d3720ae6..344da18846f3b7 100644 --- a/x-pack/plugins/spaces/server/lib/utils/namespace.ts +++ b/x-pack/plugins/spaces/server/lib/utils/namespace.ts @@ -6,8 +6,6 @@ import { SavedObjectsUtils } from '../../../../../../src/core/server'; -export const ALL_SPACES_STRING = '*'; - /** * Converts a Space ID string to its namespace ID representation. Note that a Space ID string is equivalent to a namespace string. * diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts index 06bce2684a36e0..7acf9e3e6e3d02 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts @@ -7,9 +7,9 @@ import { schema } from '@kbn/config-schema'; import { wrapError } from '../../../lib/errors'; import { ExternalRouteDeps } from '.'; +import { ALL_SPACES_ID } from '../../../../common/constants'; import { SPACE_ID_REGEX } from '../../../lib/space_schema'; import { createLicensedRouteHandler } from '../../lib'; -import { ALL_SPACES_STRING } from '../../../lib/utils/namespace'; const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); export function initShareToSpacesApi(deps: ExternalRouteDeps) { @@ -19,7 +19,7 @@ export function initShareToSpacesApi(deps: ExternalRouteDeps) { spaces: schema.arrayOf( schema.string({ validate: (value) => { - if (value !== ALL_SPACES_STRING && !SPACE_ID_REGEX.test(value)) { + if (value !== ALL_SPACES_ID && !SPACE_ID_REGEX.test(value)) { return `lower case, a-z, 0-9, "_", and "-" are allowed, OR "*"`; } }, diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 49c2df0a40ce8d..4c8e93acd68ac0 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -20,8 +20,9 @@ import { SavedObjectsUtils, ISavedObjectTypeRegistry, } from '../../../../../src/core/server'; +import { ALL_SPACES_ID } from '../../common/constants'; import { SpacesServiceSetup } from '../spaces_service/spaces_service'; -import { ALL_SPACES_STRING, spaceIdToNamespace } from '../lib/utils/namespace'; +import { spaceIdToNamespace } from '../lib/utils/namespace'; import { SpacesClient } from '../lib/spaces_client'; interface SpacesSavedObjectsClientOptions { @@ -169,7 +170,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { try { const availableSpaces = await spacesClient.getAll('findSavedObjects'); - if (namespaces.includes(ALL_SPACES_STRING)) { + if (namespaces.includes(ALL_SPACES_ID)) { namespaces = availableSpaces.map((space) => space.id); } else { namespaces = namespaces.filter((namespace) => From 790a437101b94da9e1cc8cdf4068317f4dcca066 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Fri, 2 Oct 2020 15:16:50 -0400 Subject: [PATCH 24/25] Rename `initialNamespaces` to `namespaces` --- docs/api/saved-objects/bulk_create.asciidoc | 2 +- docs/api/saved-objects/create.asciidoc | 2 +- ...ore-server.savedobjectsbulkcreateobject.md | 2 +- ...avedobjectsbulkcreateobject.namespaces.md} | 6 +-- ...n-core-server.savedobjectscreateoptions.md | 2 +- ...r.savedobjectscreateoptions.namespaces.md} | 6 +-- .../saved_objects/routes/bulk_create.ts | 2 +- .../server/saved_objects/routes/create.ts | 6 +-- .../service/lib/repository.test.js | 38 +++++++++---------- .../saved_objects/service/lib/repository.ts | 28 +++++++------- .../service/saved_objects_client.ts | 4 +- src/core/server/server.api.md | 4 +- ...ecure_saved_objects_client_wrapper.test.ts | 10 ++--- .../secure_saved_objects_client_wrapper.ts | 4 +- .../common/suites/bulk_create.ts | 2 +- .../common/suites/create.ts | 2 +- 16 files changed, 59 insertions(+), 61 deletions(-) rename docs/development/core/server/{kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md => kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md} (68%) rename docs/development/core/server/{kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md => kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md} (69%) diff --git a/docs/api/saved-objects/bulk_create.asciidoc b/docs/api/saved-objects/bulk_create.asciidoc index e77559f5d86448..5149cef3d30c6c 100644 --- a/docs/api/saved-objects/bulk_create.asciidoc +++ b/docs/api/saved-objects/bulk_create.asciidoc @@ -41,7 +41,7 @@ experimental[] Create multiple {kib} saved objects. `references`:: (Optional, array) Objects with `name`, `id`, and `type` properties that describe the other saved objects in the referenced object. To refer to the other saved object, use `name` in the attributes. Never use `id` to refer to the other saved object. `id` can be automatically updated during migrations, import, or export. -`initialNamespaces`:: +`namespaces`:: (Optional, string array) Identifiers for the <> in which this object should be created. If this is not provided, the object will be created in the current space. diff --git a/docs/api/saved-objects/create.asciidoc b/docs/api/saved-objects/create.asciidoc index fac4f2bf109fab..c8cd9c8bfca277 100644 --- a/docs/api/saved-objects/create.asciidoc +++ b/docs/api/saved-objects/create.asciidoc @@ -46,7 +46,7 @@ any data that you send to the API is properly formed. `references`:: (Optional, array) Objects with `name`, `id`, and `type` properties that describe the other saved objects that this object references. Use `name` in attributes to refer to the other saved object, but never the `id`, which can update automatically during migrations or import/export. -`initialNamespaces`:: +`namespaces`:: (Optional, string array) Identifiers for the <> in which this object should be created. If this is not provided, the object will be created in the current space. diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 5ac5f6d9807bd3..aabbfeeff75af4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -17,8 +17,8 @@ export interface SavedObjectsBulkCreateObject | --- | --- | --- | | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | | [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | string | | -| [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | | [migrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [namespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | | [originId](./kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [references](./kibana-plugin-core-server.savedobjectsbulkcreateobject.references.md) | SavedObjectReference[] | | | [type](./kibana-plugin-core-server.savedobjectsbulkcreateobject.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md similarity index 68% rename from docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md rename to docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md index 3db8bbadfbd6bf..7db1c53c67b527 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md @@ -1,8 +1,8 @@ -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) > [initialNamespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.initialnamespaces.md) +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) > [namespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md) -## SavedObjectsBulkCreateObject.initialNamespaces property +## SavedObjectsBulkCreateObject.namespaces property Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). @@ -11,5 +11,5 @@ Note: this can only be used for multi-namespace object types. Signature: ```typescript -initialNamespaces?: string[]; +namespaces?: string[]; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index e6d306784f8ae3..63aebf6c5e7918 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -16,8 +16,8 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | Property | Type | Description | | --- | --- | --- | | [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | string | (not recommended) Specify an id for the document | -| [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | | [migrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [namespaces](./kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | | [originId](./kibana-plugin-core-server.savedobjectscreateoptions.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [overwrite](./kibana-plugin-core-server.savedobjectscreateoptions.overwrite.md) | boolean | Overwrite existing documents (defaults to false) | | [references](./kibana-plugin-core-server.savedobjectscreateoptions.references.md) | SavedObjectReference[] | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md similarity index 69% rename from docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md rename to docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md index 262b0997cb9050..67804999dfd442 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md @@ -1,8 +1,8 @@ -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) > [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) > [namespaces](./kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md) -## SavedObjectsCreateOptions.initialNamespaces property +## SavedObjectsCreateOptions.namespaces property Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). @@ -11,5 +11,5 @@ Note: this can only be used for multi-namespace object types. Signature: ```typescript -initialNamespaces?: string[]; +namespaces?: string[]; ``` diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index b048c5d8f99bfc..0f925d61ead98b 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -44,7 +44,7 @@ export const registerBulkCreateRoute = (router: IRouter) => { }) ) ), - initialNamespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + namespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), }) ), }, diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index 816315705a375a..191dbfaa0dbf1d 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -44,16 +44,16 @@ export const registerCreateRoute = (router: IRouter) => { }) ) ), - initialNamespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + namespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { type, id } = req.params; const { overwrite } = req.query; - const { attributes, migrationVersion, references, initialNamespaces } = req.body; + const { attributes, migrationVersion, references, namespaces } = req.body; - const options = { id, overwrite, migrationVersion, references, initialNamespaces }; + const options = { id, overwrite, migrationVersion, references, namespaces }; const result = await context.core.savedObjects.client.create(type, attributes, options); return res.ok({ body: result }); }) diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 9e06994ecfb7d6..10c7f143e52b92 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -635,13 +635,13 @@ describe('SavedObjectsRepository', () => { await test(namespace); }); - it(`adds initialNamespaces instead of namespaces`, async () => { + it(`adds namespaces instead of namespace`, async () => { const test = async (namespace) => { const ns2 = 'bar-namespace'; const ns3 = 'baz-namespace'; const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2] }, - { ...obj2, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns3] }, + { ...obj1, type: MULTI_NAMESPACE_TYPE, namespaces: [ns2] }, + { ...obj2, type: MULTI_NAMESPACE_TYPE, namespaces: [ns3] }, ]; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const body = [ @@ -758,15 +758,15 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); - it(`returns error when initialNamespaces is used with a non-multi-namespace object`, async () => { + it(`returns error when namespaces is used with a non-multi-namespace object`, async () => { const test = async (objType) => { - const obj = { ...obj3, type: objType, initialNamespaces: [] }; + const obj = { ...obj3, type: objType, namespaces: [] }; await bulkCreateError( obj, undefined, expectErrorResult( obj, - createBadRequestError('"initialNamespaces" can only be used on multi-namespace types') + createBadRequestError('"namespaces" can only be used on multi-namespace types') ) ); }; @@ -774,14 +774,14 @@ describe('SavedObjectsRepository', () => { await test(NAMESPACE_AGNOSTIC_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { - const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; + it(`throws when options.namespaces is used with a multi-namespace type and is empty`, async () => { + const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, namespaces: [] }; await bulkCreateError( obj, undefined, expectErrorResult( obj, - createBadRequestError('"initialNamespaces" must be a non-empty array of strings') + createBadRequestError('"namespaces" must be a non-empty array of strings') ) ); }); @@ -1993,13 +1993,13 @@ describe('SavedObjectsRepository', () => { ); }); - it(`adds initialNamespaces instead of namespaces`, async () => { - const options = { id, namespace, initialNamespaces: ['bar-namespace', 'baz-namespace'] }; + it(`adds namespaces instead of namespace`, async () => { + const options = { id, namespace, namespaces: ['bar-namespace', 'baz-namespace'] }; await createSuccess(MULTI_NAMESPACE_TYPE, attributes, options); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}`, - body: expect.objectContaining({ namespaces: options.initialNamespaces }), + body: expect.objectContaining({ namespaces: options.namespaces }), }), expect.anything() ); @@ -2021,25 +2021,23 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when options.initialNamespaces is used with a non-multi-namespace object`, async () => { + it(`throws when options.namespaces is used with a non-multi-namespace object`, async () => { const test = async (objType) => { await expect( - savedObjectsRepository.create(objType, attributes, { initialNamespaces: [namespace] }) + savedObjectsRepository.create(objType, attributes, { namespaces: [namespace] }) ).rejects.toThrowError( - createBadRequestError( - '"options.initialNamespaces" can only be used on multi-namespace types' - ) + createBadRequestError('"options.namespaces" can only be used on multi-namespace types') ); }; await test('dashboard'); await test(NAMESPACE_AGNOSTIC_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.namespaces is used with a multi-namespace type and is empty`, async () => { await expect( - savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) + savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { namespaces: [] }) ).rejects.toThrowError( - createBadRequestError('"options.initialNamespaces" must be a non-empty array of strings') + createBadRequestError('"options.namespaces" must be a non-empty array of strings') ); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 39aacd6b05b7b1..bae96ceec27831 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -230,19 +230,19 @@ export class SavedObjectsRepository { references = [], refresh = DEFAULT_REFRESH_SETTING, originId, - initialNamespaces, + namespaces, version, } = options; const namespace = normalizeNamespace(options.namespace); - if (initialNamespaces) { + if (namespaces) { if (!this._registry.isMultiNamespace(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( - '"options.initialNamespaces" can only be used on multi-namespace types' + '"options.namespaces" can only be used on multi-namespace types' ); - } else if (!initialNamespaces.length) { + } else if (!namespaces.length) { throw SavedObjectsErrorHelpers.createBadRequestError( - '"options.initialNamespaces" must be a non-empty array of strings' + '"options.namespaces" must be a non-empty array of strings' ); } } @@ -262,9 +262,9 @@ export class SavedObjectsRepository { // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces // note: this check throws an error if the object is found but does not exist in this namespace const existingNamespaces = await this.preflightGetNamespaces(type, id, namespace); - savedObjectNamespaces = initialNamespaces || existingNamespaces; + savedObjectNamespaces = namespaces || existingNamespaces; } else { - savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); + savedObjectNamespaces = namespaces || getSavedObjectNamespaces(namespace); } } @@ -323,14 +323,14 @@ export class SavedObjectsRepository { let error: DecoratedError | undefined; if (!this._allowedTypes.includes(object.type)) { error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); - } else if (object.initialNamespaces) { + } else if (object.namespaces) { if (!this._registry.isMultiNamespace(object.type)) { error = SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" can only be used on multi-namespace types' + '"namespaces" can only be used on multi-namespace types' ); - } else if (!object.initialNamespaces.length) { + } else if (!object.namespaces.length) { error = SavedObjectsErrorHelpers.createBadRequestError( - '"initialNamespaces" must be a non-empty array of strings' + '"namespaces" must be a non-empty array of strings' ); } } @@ -388,7 +388,7 @@ export class SavedObjectsRepository { let versionProperties; const { esRequestIndex, - object: { initialNamespaces, version, ...object }, + object: { namespaces, version, ...object }, method, } = expectedBulkGetResult.value; if (esRequestIndex !== undefined) { @@ -410,13 +410,13 @@ export class SavedObjectsRepository { }; } savedObjectNamespaces = - initialNamespaces || getSavedObjectNamespaces(namespace, docFound && actualResult); + namespaces || getSavedObjectNamespaces(namespace, docFound && actualResult); versionProperties = getExpectedVersionProperties(version, actualResult); } else { if (this._registry.isSingleNamespace(object.type)) { savedObjectNamespace = namespace; } else if (this._registry.isMultiNamespace(object.type)) { - savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); + savedObjectNamespaces = namespaces || getSavedObjectNamespaces(namespace); } versionProperties = getExpectedVersionProperties(version); } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 6782998d1bf1ea..d2b3b89b928c7f 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -56,7 +56,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * * Note: this can only be used for multi-namespace object types. */ - initialNamespaces?: string[]; + namespaces?: string[]; } /** @@ -79,7 +79,7 @@ export interface SavedObjectsBulkCreateObject { * * Note: this can only be used for multi-namespace object types. */ - initialNamespaces?: string[]; + namespaces?: string[]; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index c8d6c296ca0642..7742dad150cfab 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1819,8 +1819,8 @@ export interface SavedObjectsBulkCreateObject { attributes: T; // (undocumented) id?: string; - initialNamespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; + namespaces?: string[]; originId?: string; // (undocumented) references?: SavedObjectReference[]; @@ -1977,8 +1977,8 @@ export interface SavedObjectsCoreFieldMapping { // @public (undocumented) export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { id?: string; - initialNamespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; + namespaces?: string[]; originId?: string; overwrite?: boolean; // (undocumented) diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index af1aaf16f7fedb..c7e5cee1ed18c2 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -427,10 +427,10 @@ describe('#bulkCreate', () => { await expectPrivilegeCheck(client.bulkCreate, { objects, options }, [namespace]); }); - test(`checks privileges for user, actions, namespace, and initialNamespaces`, async () => { + test(`checks privileges for user, actions, namespace, and namespaces`, async () => { const objects = [ - { ...obj1, initialNamespaces: 'another-ns' }, - { ...obj2, initialNamespaces: 'yet-another-ns' }, + { ...obj1, namespaces: 'another-ns' }, + { ...obj2, namespaces: 'yet-another-ns' }, ]; const options = { namespace }; await expectPrivilegeCheck(client.bulkCreate, { objects, options }, [ @@ -601,8 +601,8 @@ describe('#create', () => { await expectPrivilegeCheck(client.create, { type, attributes, options }, [namespace]); }); - test(`checks privileges for user, actions, namespace, and initialNamespaces`, async () => { - const options = { namespace, initialNamespaces: ['another-ns', 'yet-another-ns'] }; + test(`checks privileges for user, actions, namespace, and namespaces`, async () => { + const options = { namespace, namespaces: ['another-ns', 'yet-another-ns'] }; await expectPrivilegeCheck(client.create, { type, attributes, options }, [ namespace, 'another-ns', 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 d94dac942845ec..ad0bc085eb8e2f 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 @@ -86,7 +86,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsCreateOptions = {} ) { const args = { type, attributes, options }; - const namespaces = [options.namespace, ...(options.initialNamespaces || [])]; + const namespaces = [options.namespace, ...(options.namespaces || [])]; await this.ensureAuthorized(type, 'create', namespaces, { args }); const savedObject = await this.baseClient.create(type, attributes, options); @@ -114,7 +114,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { const args = { objects, options }; const namespaces = objects.reduce( - (acc, { initialNamespaces = [] }) => { + (acc, { namespaces: initialNamespaces = [] }) => { return acc.concat(initialNamespaces); }, [options.namespace] diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index 6abda8f51ed5a8..e10c1905d85418 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -65,7 +65,7 @@ export const TEST_CASES: Record = Object.freeze({ const createRequest = ({ type, id, initialNamespaces }: BulkCreateTestCase) => ({ type, id, - ...(initialNamespaces && { initialNamespaces }), + ...(initialNamespaces && { namespaces: initialNamespaces }), }); export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts index fb7f3c5c61618d..1b3902be37d722 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -127,7 +127,7 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe const path = `${type}${id ? `/${id}` : ''}`; const requestBody = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL }, - ...(initialNamespaces && { initialNamespaces }), + ...(initialNamespaces && { namespaces: initialNamespaces }), }; const query = test.overwrite ? '?overwrite=true' : ''; await supertest From 3c0aa6bad3051ba51c4f395db91e55cef5526eda Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Sat, 3 Oct 2020 16:19:44 -0400 Subject: [PATCH 25/25] Fix API integration tests A holdover from the legacy test suite checked to ensure that saved objects could not be created with the `namespace` or `namespaces` fields. This isn't necessary for an API integration test -- the unit test suite covers this scenario -- and it's invalid now that `namespaces` is a valid field. So I removed these test cases. --- .../spaces_only/apis/bulk_create.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index d06109587c3b31..e0ba6839530668 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; import { SPACES } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -76,21 +75,7 @@ export default function ({ getService }: FtrProviderContext) { return createTestDefinitions(testCases, false, overwrite, { spaceId, singleRequest: true, - }).concat( - ['namespace', 'namespaces'].map((key) => ({ - title: `(bad request) when ${key} is specified on the saved object`, - request: [{ type: 'isolatedtype', id: 'some-id', [key]: 'any-value' }] as any, - responseStatusCode: 400, - responseBody: async (response: Record) => { - expect(response.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: `[request body.0.${key}]: definition for this key is missing`, - }); - }, - overwrite, - })) - ); + }); }; describe('_bulk_create', () => {