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 af587b0e7af10..9c342cb4d843e 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 0000000000000..f869d1f863a9f --- /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 760c30edcdfb5..245819e44d37d 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 d119455336212..d99397d2a050c 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 a58f400ec3e1d..ff8642a34929f 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 0e72ad2fec06c..1dafd93d57946 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 a83c86e585628..a40fce401ec1e 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 8c96116de49cb..edfbbc4c6b408 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 450be3b0e9a6c..cb7501dd5d47f 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 228e7977f99ac..859bb2b7e8fe2 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 62b229f831562..eed67b6779679 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 4f379d5d1cbb9..f1aee480c1061 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 82045a9e288ce..eab089084ca94 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 1dafd93d57946..0b4b81d8e89bf 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 a40fce401ec1e..1feedd6c3bd78 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 e13c67a720400..330fa5066051f 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 eaddc05fa921c..8bd9c7d8312ee 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 3efe8614da1d7..69abc37089218 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 86d1b68ba761e..491c44f28bed7 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 f5de8f4b226f3..a71fd856a611f 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 344da18846f3b..a34796d3720ae 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 ee61ccd2d5e41..3f4e439a8d683 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 d03185ea7aa09..e2e261ef5b827 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 a65e0431aef92..49c2df0a40ce8 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 4c0447c29c8f9..d9d5c6f9c5808 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 190b12e038b27..e8558acc2c1f7 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 9d4b5e80e9c3d..395a343a2af1e 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 a9c552d4ccd78..7b21c2f0245fa 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 4eb967a952c60..a1addda1cdd1f 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 381306f810122..c7243d8db25fe 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 93ae439d01166..e0faae9a074b0 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 4a35bdd03e4dd..4878d9d81dbf6 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 1e11d1fc61110..3f4341de6cfc5 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 7353dafb5e1b5..7bc3e027bfade 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 eed67b6779679..436f09e8d2ee0 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 dabe174af4d4b..b554eb55b0adb 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 0b531a3dccc1a..a319a73c6a98a 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 792fe63e5932d..b0f4f13f268c9 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 fec6f2b7de715..a976ce08adb1f 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 cc2c5e2e7fc00..71743b6267e6d 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 d305e08da1b32..96eddf1f8bd3c 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 39ceb5a70d1b2..2a19c56f80ce6 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 b7c6ecef979bd..e0847ac5fd08a 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 f1aee480c1061..4caf112a59b27 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 0f105b939960f..5eed2839b6172 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 34be3b7408432..df763dba6d18a 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 91134dd14bd8a..22734e95da0b5 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 c1fd350101fd4..d6e22abf4af24 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 ef47b09eddbc8..7d9fcc8e46434 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 37bb2ec920c1e..7eef9a95f5238 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 b51ec303fadf3..e789377b93fe1 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 10e57b4db82dc..2baf0414117e4 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 eab089084ca94..66309a4be4460 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 b0fed3e13b9af..a56216537a365 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 a36249528540b..3009fa0bd75a4 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 1431a61b1cbe0..721a6b2bf7108 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 31ef6fb25b2f2..7a004290249ca 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 7e528c23c20a0..5ce6c0ce6b7c5 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 3b0f5f8570aa3..9b8baa7f22a2b 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 26c736034501f..ee7a2fb731657 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 cb9219b1ba2ed..eba7e2033eadf 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 219190cb28002..54d636c938b58 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 0748aa797264c..0169d4eb4c64b 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 ddd029c8d7d68..937aaff059580 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 4b120a71213b7..34406d3258aa4 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 25ba986a12fd8..8b8e449b3c323 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 2c4506b723533..bb0e6f858cbab 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 491c44f28bed7..1311a27b54b81 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 4f572b49ee5ff..e77559f5d8644 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 e6f3301bfea2b..fac4f2bf109fa 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 0000000000000..3db8bbadfbd6b --- /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 019d30570ab36..5ac5f6d9807bd 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 0000000000000..262b0997cb905 --- /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 d936829443753..e6d306784f8ae 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 af1a7bd2af9b7..b048c5d8f99bf 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 6cf906a3b2895..816315705a375 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 0b4b81d8e89bf..9e06994ecfb7d 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 1feedd6c3bd78..39aacd6b05b7b 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 edfbbc4c6b408..6782998d1bf1e 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 cb7501dd5d47f..c8d6c296ca064 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 1311a27b54b81..d58413ec5c271 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 a71fd856a611f..95da13a7228d6 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 b1608946b8e62..6abda8f51ed5a 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 7e28d5ed9ed94..fb7f3c5c61618 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 e0faae9a074b0..a34bb4b3e78e7 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 7bc3e027bfade..37328c0ffc342 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 71743b6267e6d..dc340f2c2aa7c 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 e0847ac5fd08a..f469ffc97fbe0 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 7d9fcc8e46434..d06109587c3b3 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 2baf0414117e4..c5013a0f0ddd3 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 c17a2dcb1a831..27656cd912695 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 ad84ea85d5e54..717f77e562c42 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 0000000000000..40bdb41cd7aec --- /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 93d7bb0170519..7ce2ca109941f 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 fe41f4a5fadc8..8b62166baaaa6 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 40bdb41cd7aec..041728a3eac0d 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 7ce2ca109941f..b34287a3c5c44 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 0000000000000..58f0feea3d2f3 --- /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 27656cd912695..804b1a88d6931 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 053fcb4fdabf8..4b5c144323ba7 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 ce93591f492f1..fa8ef1882099c 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 dce6de908cfcb..bc1e4c3fe4a44 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 079f690bfe546..dd93cffd28dd7 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 3f4e439a8d683..0000000000000 --- 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 e2e261ef5b827..0000000000000 --- 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 0000000000000..e330cd7c660c2 --- /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 0000000000000..1ffa94cfe80be --- /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 10f6292abf319..e38842b8799ac 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 1090b029069d2..4502b081034aa 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 5ef0b5375d796..50e7182b76686 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 117947fc22f50..0000000000000 --- 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 9e8bfb6ad795f..90448a5dd0422 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 e3a02d3807399..6bd094d64ec74 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 cd31a4aa17fc3..1d86d0664407a 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 58f0feea3d2f3..52fc0bb32f5a4 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 82a30dabe5beb..9ba4a7c537a04 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 0000000000000..3baa21f68d4f3 --- /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 0000000000000..4ad37094b18af --- /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 804b1a88d6931..13826a519b1e8 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 4b5c144323ba7..929f33cdf021b 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 717f77e562c42..0aa545f79d99e 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 ba9a6473999df..677632e942e9a 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 0f0fa7d22214f..6ce4c49c528af 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 9f6e57c355380..892731a0c5739 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 f666c823bd365..0888448753353 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 508669361c23f..06cf3ef17dc82 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 2daf9ab420efc..98b00c58bf27d 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 4502b081034aa..397ef6e20dfa8 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 50e7182b76686..e0fdc64285d5f 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 1ffa94cfe80be..cc3573896ca8f 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 3b5f5630e8060..3a5dcb51e2c42 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 904b9cce09d4e..6e53b169b8bed 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 5970c9a8571c4..c681d9bd758ad 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 6a10eb44d9ca4..beed3e6fe0a18 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 d879a71cc2269..5011c0299abe8 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 6b8680271635b..00a364bb7543e 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 20824af38af0f..fab651379ea6a 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 1bc3dc8066520..0adf55ed6bebb 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 1d86d0664407a..2a08f41266456 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 9ba4a7c537a04..a8e20f8135979 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 929f33cdf021b..ceba7e2097746 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 52fc0bb32f5a4..f4fcda0d451e7 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 44b6601daa7ff..a0d63c0a9dd6f 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 f287cc04280ac..4a2426a9e8a40 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 7c0ca7dcaa392..6b70e25eb448d 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 d58413ec5c271..ecf95a142a769 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 95da13a7228d6..34c8a9d2df783 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 33f1aae70ea00..bd47fe7b8b877 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 0000000000000..5531953030405 --- /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 0000000000000..71ba89d5b87e2 --- /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 0000000000000..6dce93b81c7fc --- /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 f4fcda0d451e7..afa65cc7ad7df 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 a8e20f8135979..3cd7093b0bb20 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 4ad37094b18af..2f1a3e0d459ee 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 13826a519b1e8..ad49161ddd705 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 b34287a3c5c44..0f988cf5a152d 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 06cf3ef17dc82..7f005e37d96e9 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 98b00c58bf27d..c81f7c17b7770 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 cc3573896ca8f..5bf3b2779ba2a 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 395a343a2af1e..511d183145a30 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 0dd89f2c5f3c1..6da0b93e1461f 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 6f721c91fbd67..2b1268b11a0ff 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 397ef6e20dfa8..4502b081034aa 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 e0fdc64285d5f..50e7182b76686 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 af54effcaeca6..a9ba5ac2dc6de 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 dd93cffd28dd7..f093f26b4bdee 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 e330cd7c660c2..3af1d9d245d10 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 5bf3b2779ba2a..06bce2684a36e 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 ecf95a142a769..af1aaf16f7fed 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 34c8a9d2df783..d94dac942845e 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 3cd7093b0bb20..29303a478daeb 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 2f1a3e0d459ee..dca52e6e529a1 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 b2729c504ce23..6461a3299f09b 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 0aa545f79d99e..bc196208ab35c 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 bc1e4c3fe4a44..341e5cf3bfbe0 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 5461aaf1e36ea..4fe81027c3508 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 ac9a46ee9c3fa..4786399936662 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 a9b701a8ea395..deb8434497ae7 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 6aa89b36b020a..6aeec251e33e4 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 ebdffa20a6c8e..326837f8995f0 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 54d636c938b58..f2a3c69a91196 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 0169d4eb4c64b..d318609e11570 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 937aaff059580..40f87cb90933f 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 6dce93b81c7fc..87b54dd4e2ef3 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 a34796d3720ae..344da18846f3b 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 06bce2684a36e..7acf9e3e6e3d0 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 49c2df0a40ce8..4c8e93acd68ac 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 e77559f5d8644..5149cef3d30c6 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 fac4f2bf109fa..c8cd9c8bfca27 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 5ac5f6d9807bd..aabbfeeff75af 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 3db8bbadfbd6b..7db1c53c67b52 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 e6d306784f8ae..63aebf6c5e791 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 262b0997cb905..67804999dfd44 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 b048c5d8f99bf..0f925d61ead98 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 816315705a375..191dbfaa0dbf1 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 9e06994ecfb7d..10c7f143e52b9 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 39aacd6b05b7b..bae96ceec2783 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 6782998d1bf1e..d2b3b89b928c7 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 c8d6c296ca064..7742dad150cfa 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 af1aaf16f7fed..c7e5cee1ed18c 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 d94dac942845e..ad0bc085eb8e2 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 6abda8f51ed5a..e10c1905d8541 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 fb7f3c5c61618..1b3902be37d72 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 d06109587c3b3..e0ba683953066 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', () => {