From 341052ad338fa84b0f416e1f81d348f65f1b35d4 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Thu, 20 Aug 2020 10:22:36 -0400 Subject: [PATCH] Change `bulkUpdate` method to allow per-object namespaces Includes docs changes and integration tests. --- ...ore-server.savedobjectsbulkupdateobject.md | 1 + ....savedobjectsbulkupdateobject.namespace.md | 15 +++ .../saved_objects/routes/bulk_update.ts | 1 + .../service/lib/repository.test.js | 113 +++++++++++------- .../saved_objects/service/lib/repository.ts | 35 ++++-- .../service/saved_objects_client.ts | 7 ++ src/core/server/server.api.md | 1 + ...ecure_saved_objects_client_wrapper.test.ts | 21 +++- .../secure_saved_objects_client_wrapper.ts | 15 ++- .../common/suites/bulk_update.ts | 14 ++- .../security_and_spaces/apis/bulk_update.ts | 58 ++++++--- .../security_only/apis/bulk_update.ts | 33 ++++- .../spaces_only/apis/bulk_update.ts | 51 +++++--- 13 files changed, 266 insertions(+), 99 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkupdateobject.namespace.md diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkupdateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkupdateobject.md index e079e0fa51aac9..3ae529270bf32d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkupdateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkupdateobject.md @@ -17,5 +17,6 @@ export interface SavedObjectsBulkUpdateObject extends PickPartial<T> | The data for a Saved Object is stored as an object in the attributes property. | | [id](./kibana-plugin-core-server.savedobjectsbulkupdateobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | +| [namespace](./kibana-plugin-core-server.savedobjectsbulkupdateobject.namespace.md) | string | Optional namespace string to use for this document. If this is defined, it will supersede the namespace ID that is in [SavedObjectsUpdateOptions](./kibana-plugin-core-server.savedobjectsupdateoptions.md).Note: the default namespace's string representation is 'default', and its ID representation is undefined. | | [type](./kibana-plugin-core-server.savedobjectsbulkupdateobject.type.md) | string | The type of this Saved Object. Each plugin can define it's own custom Saved Object types. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkupdateobject.namespace.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkupdateobject.namespace.md new file mode 100644 index 00000000000000..5796e70eda9809 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkupdateobject.namespace.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkUpdateObject](./kibana-plugin-core-server.savedobjectsbulkupdateobject.md) > [namespace](./kibana-plugin-core-server.savedobjectsbulkupdateobject.namespace.md) + +## SavedObjectsBulkUpdateObject.namespace property + +Optional namespace string to use for this document. If this is defined, it will supersede the namespace ID that is in [SavedObjectsUpdateOptions](./kibana-plugin-core-server.savedobjectsupdateoptions.md). + +Note: the default namespace's string representation is `'default'`, and its ID representation is `undefined`. + +Signature: + +```typescript +namespace?: string; +``` diff --git a/src/core/server/saved_objects/routes/bulk_update.ts b/src/core/server/saved_objects/routes/bulk_update.ts index c112833b29f3f4..882213644146a1 100644 --- a/src/core/server/saved_objects/routes/bulk_update.ts +++ b/src/core/server/saved_objects/routes/bulk_update.ts @@ -40,6 +40,7 @@ export const registerBulkUpdateRoute = (router: IRouter) => { }) ) ), + namespace: schema.maybe(schema.string({ minLength: 1 })), }) ), }, 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 941c4091a66a76..87337d643ff1ff 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -154,26 +154,35 @@ describe('SavedObjectsRepository', () => { validateDoc: jest.fn(), }); - const getMockGetResponse = ({ type, id, references, namespace }) => ({ - // NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these - found: true, - _id: `${registry.isSingleNamespace(type) && namespace ? `${namespace}:` : ''}${type}:${id}`, - ...mockVersionProps, - _source: { - ...(registry.isSingleNamespace(type) && { namespace }), - ...(registry.isMultiNamespace(type) && { namespaces: [namespace ?? 'default'] }), - type, - [type]: { title: 'Testing' }, - references, - specialProperty: 'specialValue', - ...mockTimestampFields, - }, - }); + const getMockGetResponse = ({ type, id, references, namespace: objectNamespace }, namespace) => { + const namespaceId = + // eslint-disable-next-line no-nested-ternary + objectNamespace !== undefined + ? objectNamespace !== 'default' + ? objectNamespace + : undefined + : namespace; + return { + // NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these + found: true, + _id: `${ + registry.isSingleNamespace(type) && namespaceId ? `${namespaceId}:` : '' + }${type}:${id}`, + ...mockVersionProps, + _source: { + ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), + ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), + type, + [type]: { title: 'Testing' }, + references, + specialProperty: 'specialValue', + ...mockTimestampFields, + }, + }; + }; const getMockMgetResponse = (objects, namespace) => ({ - docs: objects.map((obj) => - obj.found === false ? obj : getMockGetResponse({ ...obj, namespace }) - ), + docs: objects.map((obj) => (obj.found === false ? obj : getMockGetResponse(obj, namespace))), }); expect.extend({ @@ -1311,29 +1320,57 @@ describe('SavedObjectsRepository', () => { const getId = (type, id) => `${namespace}:${type}:${id}`; await bulkUpdateSuccess([obj1, obj2], { namespace }); expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); + + jest.clearAllMocks(); + // test again with object namespace string that supersedes the operation's namespace ID + await bulkUpdateSuccess([ + { ...obj1, namespace }, + { ...obj2, namespace }, + ]); + expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; await bulkUpdateSuccess([obj1, obj2]); expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); + + jest.clearAllMocks(); + // test again with object namespace string that supersedes the operation's namespace ID + await bulkUpdateSuccess( + [ + { ...obj1, namespace: 'default' }, + { ...obj2, namespace: 'default' }, + ], + { namespace } + ); + expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; - const objects1 = [{ ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }]; - await bulkUpdateSuccess(objects1, { namespace }); - expectClientCallArgsAction(objects1, { method: 'update', getId }); - client.bulk.mockClear(); const overrides = { // bulkUpdate uses a preflight `get` request for multi-namespace saved objects, and specifies that version on `update` // we aren't testing for this here, but we need to include Jest assertions so this test doesn't fail if_primary_term: expect.any(Number), if_seq_no: expect.any(Number), }; - const objects2 = [{ ...obj2, type: MULTI_NAMESPACE_TYPE }]; - await bulkUpdateSuccess(objects2, { namespace }); - expectClientCallArgsAction(objects2, { method: 'update', getId, overrides }, 2); + const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + + await bulkUpdateSuccess([_obj1], { namespace }); + expectClientCallArgsAction([_obj1], { method: 'update', getId }); + client.bulk.mockClear(); + await bulkUpdateSuccess([_obj2], { namespace }); + expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }, 2); + + jest.clearAllMocks(); + // test again with object namespace string that supersedes the operation's namespace ID + await bulkUpdateSuccess([{ ..._obj1, namespace }]); + expectClientCallArgsAction([_obj1], { method: 'update', getId }); + client.bulk.mockClear(); + await bulkUpdateSuccess([{ ..._obj2, namespace }]); + expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }, 2); }); }); @@ -1684,11 +1721,7 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ - type: MULTI_NAMESPACE_TYPE, - id, - namespace: 'bar-namespace', - }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -1785,7 +1818,7 @@ describe('SavedObjectsRepository', () => { const deleteSuccess = async (type, id, options) => { if (registry.isMultiNamespace(type)) { - const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); + const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) ); @@ -1911,7 +1944,7 @@ describe('SavedObjectsRepository', () => { }); it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -2440,7 +2473,7 @@ describe('SavedObjectsRepository', () => { const namespace = 'foo-namespace'; const getSuccess = async (type, id, options) => { - const response = getMockGetResponse({ type, id, namespace: options?.namespace }); + const response = getMockGetResponse({ type, id }, options?.namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -2529,7 +2562,7 @@ describe('SavedObjectsRepository', () => { }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -2579,7 +2612,7 @@ describe('SavedObjectsRepository', () => { const incrementCounterSuccess = async (type, id, field, options) => { const isMultiNamespace = registry.isMultiNamespace(type); if (isMultiNamespace) { - const response = getMockGetResponse({ type, id, namespace: options?.namespace }); + const response = getMockGetResponse({ type, id }, options?.namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -2716,11 +2749,7 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ - type: MULTI_NAMESPACE_TYPE, - id, - namespace: 'bar-namespace', - }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -3152,7 +3181,7 @@ describe('SavedObjectsRepository', () => { const updateSuccess = async (type, id, attributes, options) => { if (registry.isMultiNamespace(type)) { - const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); + const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) ); @@ -3349,7 +3378,7 @@ describe('SavedObjectsRepository', () => { }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 941488d2264e67..0027c04395f206 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -63,7 +63,7 @@ import { } from '../../types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; -import { namespaceIdToString } from './namespace'; +import { namespaceIdToString, namespaceStringToId } from './namespace'; // 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. @@ -1131,7 +1131,9 @@ export class SavedObjectsRepository { }; } - const { attributes, references, version } = object; + const { attributes, references, version, namespace: objectNamespace } = object; + // `objectNamespace` is a namespace string, while `namespace` is a namespace ID. + // The object namespace string, if defined, will supersede the operation's namespace ID. const documentToSave = { [type]: attributes, @@ -1148,16 +1150,22 @@ export class SavedObjectsRepository { id, version, documentToSave, + objectNamespace, ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), }, }; }); + const getNamespaceId = (objectNamespace?: string) => + objectNamespace !== undefined ? namespaceStringToId(objectNamespace) : namespace; + const getNamespaceString = (objectNamespace?: string) => + objectNamespace ?? namespaceIdToString(namespace); + const bulkGetDocs = expectedBulkGetResults .filter(isRight) .filter(({ value }) => value.esRequestIndex !== undefined) - .map(({ value: { type, id } }) => ({ - _id: this._serializer.generateRawId(namespace, type, id), + .map(({ value: { type, id, objectNamespace } }) => ({ + _id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id), _index: this.getIndexForType(type), _source: ['type', 'namespaces'], })); @@ -1182,14 +1190,25 @@ export class SavedObjectsRepository { return expectedBulkGetResult; } - const { esRequestIndex, id, type, version, documentToSave } = expectedBulkGetResult.value; + const { + esRequestIndex, + id, + type, + version, + documentToSave, + objectNamespace, + } = expectedBulkGetResult.value; + let namespaces; let versionProperties; if (esRequestIndex !== undefined) { const indexFound = bulkGetResponse?.statusCode !== 404; const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; const docFound = indexFound && actualResult.found === true; - if (!docFound || !this.rawDocExistsInNamespace(actualResult, namespace)) { + if ( + !docFound || + !this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace)) + ) { return { tag: 'Left' as 'Left', error: { @@ -1205,7 +1224,7 @@ export class SavedObjectsRepository { versionProperties = getExpectedVersionProperties(version, actualResult); } else { if (this._registry.isSingleNamespace(type)) { - namespaces = [namespaceIdToString(namespace)]; + namespaces = [getNamespaceString(objectNamespace)]; } versionProperties = getExpectedVersionProperties(version); } @@ -1221,7 +1240,7 @@ export class SavedObjectsRepository { bulkUpdateParams.push( { update: { - _id: this._serializer.generateRawId(namespace, type, id), + _id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id), _index: this.getIndexForType(type), ...versionProperties, }, 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 6a9f4f5143e841..9b273bc61f61ee 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -75,6 +75,13 @@ export interface SavedObjectsBulkUpdateObject type: string; /** {@inheritdoc SavedObjectAttributes} */ attributes: Partial; + /** + * Optional namespace string to use for this document. If this is defined, it will supersede the namespace ID that is in + * {@link SavedObjectsUpdateOptions}. + * + * Note: the default namespace's string representation is `'default'`, and its ID representation is `undefined`. + **/ + namespace?: string; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index da28ca3c3d53f8..8e043e225daca1 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2079,6 +2079,7 @@ export interface SavedObjectsBulkResponse { export interface SavedObjectsBulkUpdateObject extends Pick { attributes: Partial; id: string; + namespace?: string; type: string; } 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 1cf879adc54154..605b804e651688 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 @@ -117,7 +117,11 @@ const expectSuccess = async (fn: Function, args: Record) => { return result; }; -const expectPrivilegeCheck = async (fn: Function, args: Record) => { +const expectPrivilegeCheck = async ( + fn: Function, + args: Record, + namespacesOverride?: string[] +) => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( getMockCheckPrivilegesFailure ); @@ -131,7 +135,7 @@ const expectPrivilegeCheck = async (fn: Function, args: Record) => expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( actions, - args.options?.namespace ?? args.options?.namespaces + namespacesOverride ?? args.options?.namespace ?? args.options?.namespaces ); }; @@ -468,7 +472,18 @@ describe('#bulkUpdate', () => { test(`checks privileges for user, actions, and namespace`, async () => { const objects = [obj1, obj2]; - await expectPrivilegeCheck(client.bulkUpdate, { objects, options }); + const namespacesOverride = [options.namespace]; // the bulkCreate function checks privileges as an array + await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespacesOverride); + }); + + test(`checks privileges for object namespaces if present`, async () => { + const objects = [ + { ...obj1, namespace: 'foo-ns' }, + { ...obj2, namespace: 'bar-ns' }, + ]; + const namespacesOverride = ['default', 'foo-ns', 'bar-ns']; + // use the default namespace for the options + await expectPrivilegeCheck(client.bulkUpdate, { objects, options: {} }, namespacesOverride); }); 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 621299a0f025e1..ab82c4234e49db 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 @@ -15,6 +15,7 @@ import { SavedObjectsUpdateOptions, SavedObjectsAddToNamespacesOptions, SavedObjectsDeleteFromNamespacesOptions, + namespaceIdToString, } from '../../../../../src/core/server'; import { SecurityAuditLogger } from '../audit'; import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; @@ -184,12 +185,14 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: Array> = [], options: SavedObjectsBaseOptions = {} ) { - await this.ensureAuthorized( - this.getUniqueObjectTypes(objects), - 'bulk_update', - options && options.namespace, - { objects, options } - ); + const objectNamespaces = objects + .filter(({ namespace }) => namespace !== undefined) + .map(({ namespace }) => namespace!); + const namespaces = uniq([namespaceIdToString(options?.namespace), ...objectNamespaces]); + await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, { + objects, + options, + }); const response = await this.baseClient.bulkUpdate(objects, options); return await this.redactSavedObjectsNamespaces(response); diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts index 0b5656004492a4..2e3c55f029d297 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts @@ -8,12 +8,7 @@ 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 { - 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 BulkUpdateTestDefinition extends TestDefinition { @@ -21,6 +16,7 @@ export interface BulkUpdateTestDefinition extends TestDefinition { } export type BulkUpdateTestSuite = TestSuite; export interface BulkUpdateTestCase extends TestCase { + namespace?: string; // used to define individual "object namespace" strings, e.g., bulkUpdate across multiple namespaces failure?: 404; // only used for permitted response case } @@ -30,6 +26,12 @@ const NEW_ATTRIBUTE_VAL = `Updated attribute value ${Date.now()}`; const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); +const createRequest = ({ type, id, namespace }: BulkUpdateTestCase) => ({ + type, + id, + ...(namespace && { namespace }), // individual "object namespace" string +}); + export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('bulk_update'); const expectResponseBody = ( 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 90f72e0b344494..1e11d1fc61110f 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 @@ -39,7 +39,18 @@ const createTestCases = (spaceId: string) => { ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; const allTypes = normalTypes.concat(hiddenType); - return { normalTypes, hiddenType, allTypes }; + // an "object namespace" string can be specified for individual objects (to bulkUpdate across namespaces) + const withObjectNamespaces = [ + { ...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_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 }, + CASES.NAMESPACE_AGNOSTIC, // any namespace would work and would make no difference + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + return { normalTypes, hiddenType, allTypes, withObjectNamespaces }; }; export default function ({ getService }: FtrProviderContext) { @@ -51,26 +62,42 @@ export default function ({ getService }: FtrProviderContext) { supertest ); const createTests = (spaceId: string) => { - const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + const { normalTypes, hiddenType, allTypes, withObjectNamespaces } = createTestCases(spaceId); // use singleRequest to reduce execution time and/or test combined cases + const authorizedCommon = [ + createTestDefinitions(normalTypes, false, { singleRequest: true }), + createTestDefinitions(hiddenType, true), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(); return { - unauthorized: createTestDefinitions(allTypes, true), - authorized: [ - createTestDefinitions(normalTypes, false, { singleRequest: true }), - createTestDefinitions(hiddenType, true), - createTestDefinitions(allTypes, true, { - singleRequest: true, - responseBodyOverride: expectForbidden(['hiddentype']), - }), + unauthorized: [ + createTestDefinitions(allTypes, true), + createTestDefinitions(withObjectNamespaces, true, { singleRequest: true }), + ].flat(), + authorizedAtSpace: [ + authorizedCommon, + createTestDefinitions(withObjectNamespaces, true, { singleRequest: true }), + ].flat(), + authorizedAllSpaces: [ + authorizedCommon, + createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }), + ].flat(), + superuser: [ + createTestDefinitions(allTypes, false, { singleRequest: true }), + createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }), ].flat(), - superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), }; }; describe('_bulk_update', () => { getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorized, superuser } = createTests(spaceId); + const { unauthorized, authorizedAtSpace, authorizedAllSpaces, superuser } = createTests( + spaceId + ); const _addTests = (user: TestUser, tests: BulkUpdateTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; @@ -85,8 +112,11 @@ export default function ({ getService }: FtrProviderContext) { ].forEach((user) => { _addTests(user, unauthorized); }); - [users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => { - _addTests(user, authorized); + [users.allAtSpace].forEach((user) => { + _addTests(user, authorizedAtSpace); + }); + [users.dualAll, users.allGlobally].forEach((user) => { + _addTests(user, authorizedAllSpaces); }); _addTests(users.superuser, superuser); }); 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 d42eb25b81cf55..39ceb5a70d1b23 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 @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SPACES } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -13,6 +14,11 @@ import { BulkUpdateTestDefinition, } from '../../common/suites/bulk_update'; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; const { fail404 } = testCaseFailures; const createTestCases = () => { @@ -30,7 +36,19 @@ const createTestCases = () => { ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; const allTypes = normalTypes.concat(hiddenType); - return { normalTypes, hiddenType, allTypes }; + // an "object namespace" string can be specified for individual objects (to bulkUpdate across namespaces) + // even if the Spaces plugin is disabled, this should work, as `namespace` is handled by the Core API + const withObjectNamespaces = [ + { ...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_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 }, + CASES.NAMESPACE_AGNOSTIC, // any namespace would work and would make no difference + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + return { normalTypes, hiddenType, allTypes, withObjectNamespaces }; }; export default function ({ getService }: FtrProviderContext) { @@ -42,10 +60,13 @@ export default function ({ getService }: FtrProviderContext) { supertest ); const createTests = () => { - const { normalTypes, hiddenType, allTypes } = createTestCases(); + const { normalTypes, hiddenType, allTypes, withObjectNamespaces } = createTestCases(); // use singleRequest to reduce execution time and/or test combined cases return { - unauthorized: createTestDefinitions(allTypes, true), + unauthorized: [ + createTestDefinitions(allTypes, true), + createTestDefinitions(withObjectNamespaces, true, { singleRequest: true }), + ].flat(), authorized: [ createTestDefinitions(normalTypes, false, { singleRequest: true }), createTestDefinitions(hiddenType, true), @@ -53,8 +74,12 @@ export default function ({ getService }: FtrProviderContext) { singleRequest: true, responseBodyOverride: expectForbidden(['hiddentype']), }), + createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }), + ].flat(), + superuser: [ + createTestDefinitions(allTypes, false, { singleRequest: true }), + createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }), ].flat(), - superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), }; }; 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 93e44e357918a8..dac85736f1fc54 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 @@ -16,22 +16,38 @@ const { } = SPACES; const { fail404 } = testCaseFailures; -const createTestCases = (spaceId: string) => [ +const createTestCases = (spaceId: string) => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - { ...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_DEFAULT_AND_SPACE_1, - ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), - }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, - CASES.NAMESPACE_AGNOSTIC, - { ...CASES.HIDDEN, ...fail404() }, - { ...CASES.DOES_NOT_EXIST, ...fail404() }, -]; + const normal = [ + { ...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_DEFAULT_AND_SPACE_1, + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.HIDDEN, ...fail404() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + + // an "object namespace" string can be specified for individual objects (to bulkUpdate across namespaces) + // even if the Spaces plugin is disabled, this should work, as `namespace` is handled by the Core API + const withObjectNamespaces = [ + { ...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_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 }, + CASES.NAMESPACE_AGNOSTIC, // any namespace would work and would make no difference + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + return { normal, withObjectNamespaces }; +}; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -39,8 +55,11 @@ export default function ({ getService }: FtrProviderContext) { const { addTests, createTestDefinitions } = bulkUpdateTestSuiteFactory(esArchiver, supertest); const createTests = (spaceId: string) => { - const testCases = createTestCases(spaceId); - return createTestDefinitions(testCases, false, { singleRequest: true }); + const { normal, withObjectNamespaces } = createTestCases(spaceId); + return [ + createTestDefinitions(normal, false, { singleRequest: true }), + createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }), + ].flat(); }; describe('_bulk_update', () => {