Skip to content

Commit 341052a

Browse files
committed
Change bulkUpdate method to allow per-object namespaces
Includes docs changes and integration tests.
1 parent fa6ae1c commit 341052a

File tree

13 files changed

+266
-99
lines changed

13 files changed

+266
-99
lines changed

docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkupdateobject.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ export interface SavedObjectsBulkUpdateObject<T = unknown> extends Pick<SavedObj
1717
| --- | --- | --- |
1818
| [attributes](./kibana-plugin-core-server.savedobjectsbulkupdateobject.attributes.md) | <code>Partial&lt;T&gt;</code> | The data for a Saved Object is stored as an object in the <code>attributes</code> property. |
1919
| [id](./kibana-plugin-core-server.savedobjectsbulkupdateobject.id.md) | <code>string</code> | The ID of this Saved Object, guaranteed to be unique for all objects of the same <code>type</code> |
20+
| [namespace](./kibana-plugin-core-server.savedobjectsbulkupdateobject.namespace.md) | <code>string</code> | 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 <code>'default'</code>, and its ID representation is <code>undefined</code>. |
2021
| [type](./kibana-plugin-core-server.savedobjectsbulkupdateobject.type.md) | <code>string</code> | The type of this Saved Object. Each plugin can define it's own custom Saved Object types. |
2122
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
2+
3+
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-core-server.savedobjectsbulkupdateobject.md) &gt; [namespace](./kibana-plugin-core-server.savedobjectsbulkupdateobject.namespace.md)
4+
5+
## SavedObjectsBulkUpdateObject.namespace property
6+
7+
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)<!-- -->.
8+
9+
Note: the default namespace's string representation is `'default'`<!-- -->, and its ID representation is `undefined`<!-- -->.
10+
11+
<b>Signature:</b>
12+
13+
```typescript
14+
namespace?: string;
15+
```

src/core/server/saved_objects/routes/bulk_update.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const registerBulkUpdateRoute = (router: IRouter) => {
4040
})
4141
)
4242
),
43+
namespace: schema.maybe(schema.string({ minLength: 1 })),
4344
})
4445
),
4546
},

src/core/server/saved_objects/service/lib/repository.test.js

Lines changed: 71 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -154,26 +154,35 @@ describe('SavedObjectsRepository', () => {
154154
validateDoc: jest.fn(),
155155
});
156156

157-
const getMockGetResponse = ({ type, id, references, namespace }) => ({
158-
// NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these
159-
found: true,
160-
_id: `${registry.isSingleNamespace(type) && namespace ? `${namespace}:` : ''}${type}:${id}`,
161-
...mockVersionProps,
162-
_source: {
163-
...(registry.isSingleNamespace(type) && { namespace }),
164-
...(registry.isMultiNamespace(type) && { namespaces: [namespace ?? 'default'] }),
165-
type,
166-
[type]: { title: 'Testing' },
167-
references,
168-
specialProperty: 'specialValue',
169-
...mockTimestampFields,
170-
},
171-
});
157+
const getMockGetResponse = ({ type, id, references, namespace: objectNamespace }, namespace) => {
158+
const namespaceId =
159+
// eslint-disable-next-line no-nested-ternary
160+
objectNamespace !== undefined
161+
? objectNamespace !== 'default'
162+
? objectNamespace
163+
: undefined
164+
: namespace;
165+
return {
166+
// NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these
167+
found: true,
168+
_id: `${
169+
registry.isSingleNamespace(type) && namespaceId ? `${namespaceId}:` : ''
170+
}${type}:${id}`,
171+
...mockVersionProps,
172+
_source: {
173+
...(registry.isSingleNamespace(type) && { namespace: namespaceId }),
174+
...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }),
175+
type,
176+
[type]: { title: 'Testing' },
177+
references,
178+
specialProperty: 'specialValue',
179+
...mockTimestampFields,
180+
},
181+
};
182+
};
172183

173184
const getMockMgetResponse = (objects, namespace) => ({
174-
docs: objects.map((obj) =>
175-
obj.found === false ? obj : getMockGetResponse({ ...obj, namespace })
176-
),
185+
docs: objects.map((obj) => (obj.found === false ? obj : getMockGetResponse(obj, namespace))),
177186
});
178187

179188
expect.extend({
@@ -1311,29 +1320,57 @@ describe('SavedObjectsRepository', () => {
13111320
const getId = (type, id) => `${namespace}:${type}:${id}`;
13121321
await bulkUpdateSuccess([obj1, obj2], { namespace });
13131322
expectClientCallArgsAction([obj1, obj2], { method: 'update', getId });
1323+
1324+
jest.clearAllMocks();
1325+
// test again with object namespace string that supersedes the operation's namespace ID
1326+
await bulkUpdateSuccess([
1327+
{ ...obj1, namespace },
1328+
{ ...obj2, namespace },
1329+
]);
1330+
expectClientCallArgsAction([obj1, obj2], { method: 'update', getId });
13141331
});
13151332

13161333
it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => {
13171334
const getId = (type, id) => `${type}:${id}`;
13181335
await bulkUpdateSuccess([obj1, obj2]);
13191336
expectClientCallArgsAction([obj1, obj2], { method: 'update', getId });
1337+
1338+
jest.clearAllMocks();
1339+
// test again with object namespace string that supersedes the operation's namespace ID
1340+
await bulkUpdateSuccess(
1341+
[
1342+
{ ...obj1, namespace: 'default' },
1343+
{ ...obj2, namespace: 'default' },
1344+
],
1345+
{ namespace }
1346+
);
1347+
expectClientCallArgsAction([obj1, obj2], { method: 'update', getId });
13201348
});
13211349

13221350
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
13231351
const getId = (type, id) => `${type}:${id}`;
1324-
const objects1 = [{ ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }];
1325-
await bulkUpdateSuccess(objects1, { namespace });
1326-
expectClientCallArgsAction(objects1, { method: 'update', getId });
1327-
client.bulk.mockClear();
13281352
const overrides = {
13291353
// bulkUpdate uses a preflight `get` request for multi-namespace saved objects, and specifies that version on `update`
13301354
// we aren't testing for this here, but we need to include Jest assertions so this test doesn't fail
13311355
if_primary_term: expect.any(Number),
13321356
if_seq_no: expect.any(Number),
13331357
};
1334-
const objects2 = [{ ...obj2, type: MULTI_NAMESPACE_TYPE }];
1335-
await bulkUpdateSuccess(objects2, { namespace });
1336-
expectClientCallArgsAction(objects2, { method: 'update', getId, overrides }, 2);
1358+
const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE };
1359+
const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE };
1360+
1361+
await bulkUpdateSuccess([_obj1], { namespace });
1362+
expectClientCallArgsAction([_obj1], { method: 'update', getId });
1363+
client.bulk.mockClear();
1364+
await bulkUpdateSuccess([_obj2], { namespace });
1365+
expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }, 2);
1366+
1367+
jest.clearAllMocks();
1368+
// test again with object namespace string that supersedes the operation's namespace ID
1369+
await bulkUpdateSuccess([{ ..._obj1, namespace }]);
1370+
expectClientCallArgsAction([_obj1], { method: 'update', getId });
1371+
client.bulk.mockClear();
1372+
await bulkUpdateSuccess([{ ..._obj2, namespace }]);
1373+
expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }, 2);
13371374
});
13381375
});
13391376

@@ -1684,11 +1721,7 @@ describe('SavedObjectsRepository', () => {
16841721
});
16851722

16861723
it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => {
1687-
const response = getMockGetResponse({
1688-
type: MULTI_NAMESPACE_TYPE,
1689-
id,
1690-
namespace: 'bar-namespace',
1691-
});
1724+
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace');
16921725
client.get.mockResolvedValueOnce(
16931726
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
16941727
);
@@ -1785,7 +1818,7 @@ describe('SavedObjectsRepository', () => {
17851818

17861819
const deleteSuccess = async (type, id, options) => {
17871820
if (registry.isMultiNamespace(type)) {
1788-
const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace });
1821+
const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace);
17891822
client.get.mockResolvedValueOnce(
17901823
elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse)
17911824
);
@@ -1911,7 +1944,7 @@ describe('SavedObjectsRepository', () => {
19111944
});
19121945

19131946
it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => {
1914-
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace });
1947+
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace);
19151948
client.get.mockResolvedValueOnce(
19161949
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
19171950
);
@@ -2440,7 +2473,7 @@ describe('SavedObjectsRepository', () => {
24402473
const namespace = 'foo-namespace';
24412474

24422475
const getSuccess = async (type, id, options) => {
2443-
const response = getMockGetResponse({ type, id, namespace: options?.namespace });
2476+
const response = getMockGetResponse({ type, id }, options?.namespace);
24442477
client.get.mockResolvedValueOnce(
24452478
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
24462479
);
@@ -2529,7 +2562,7 @@ describe('SavedObjectsRepository', () => {
25292562
});
25302563

25312564
it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => {
2532-
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace });
2565+
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace);
25332566
client.get.mockResolvedValueOnce(
25342567
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
25352568
);
@@ -2579,7 +2612,7 @@ describe('SavedObjectsRepository', () => {
25792612
const incrementCounterSuccess = async (type, id, field, options) => {
25802613
const isMultiNamespace = registry.isMultiNamespace(type);
25812614
if (isMultiNamespace) {
2582-
const response = getMockGetResponse({ type, id, namespace: options?.namespace });
2615+
const response = getMockGetResponse({ type, id }, options?.namespace);
25832616
client.get.mockResolvedValueOnce(
25842617
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
25852618
);
@@ -2716,11 +2749,7 @@ describe('SavedObjectsRepository', () => {
27162749
});
27172750

27182751
it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => {
2719-
const response = getMockGetResponse({
2720-
type: MULTI_NAMESPACE_TYPE,
2721-
id,
2722-
namespace: 'bar-namespace',
2723-
});
2752+
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace');
27242753
client.get.mockResolvedValueOnce(
27252754
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
27262755
);
@@ -3152,7 +3181,7 @@ describe('SavedObjectsRepository', () => {
31523181

31533182
const updateSuccess = async (type, id, attributes, options) => {
31543183
if (registry.isMultiNamespace(type)) {
3155-
const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace });
3184+
const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace);
31563185
client.get.mockResolvedValueOnce(
31573186
elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse)
31583187
);
@@ -3349,7 +3378,7 @@ describe('SavedObjectsRepository', () => {
33493378
});
33503379

33513380
it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => {
3352-
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace });
3381+
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace);
33533382
client.get.mockResolvedValueOnce(
33543383
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
33553384
);

src/core/server/saved_objects/service/lib/repository.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import {
6363
} from '../../types';
6464
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
6565
import { validateConvertFilterToKueryNode } from './filter_utils';
66-
import { namespaceIdToString } from './namespace';
66+
import { namespaceIdToString, namespaceStringToId } from './namespace';
6767

6868
// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository
6969
// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient.
@@ -1131,7 +1131,9 @@ export class SavedObjectsRepository {
11311131
};
11321132
}
11331133

1134-
const { attributes, references, version } = object;
1134+
const { attributes, references, version, namespace: objectNamespace } = object;
1135+
// `objectNamespace` is a namespace string, while `namespace` is a namespace ID.
1136+
// The object namespace string, if defined, will supersede the operation's namespace ID.
11351137

11361138
const documentToSave = {
11371139
[type]: attributes,
@@ -1148,16 +1150,22 @@ export class SavedObjectsRepository {
11481150
id,
11491151
version,
11501152
documentToSave,
1153+
objectNamespace,
11511154
...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }),
11521155
},
11531156
};
11541157
});
11551158

1159+
const getNamespaceId = (objectNamespace?: string) =>
1160+
objectNamespace !== undefined ? namespaceStringToId(objectNamespace) : namespace;
1161+
const getNamespaceString = (objectNamespace?: string) =>
1162+
objectNamespace ?? namespaceIdToString(namespace);
1163+
11561164
const bulkGetDocs = expectedBulkGetResults
11571165
.filter(isRight)
11581166
.filter(({ value }) => value.esRequestIndex !== undefined)
1159-
.map(({ value: { type, id } }) => ({
1160-
_id: this._serializer.generateRawId(namespace, type, id),
1167+
.map(({ value: { type, id, objectNamespace } }) => ({
1168+
_id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id),
11611169
_index: this.getIndexForType(type),
11621170
_source: ['type', 'namespaces'],
11631171
}));
@@ -1182,14 +1190,25 @@ export class SavedObjectsRepository {
11821190
return expectedBulkGetResult;
11831191
}
11841192

1185-
const { esRequestIndex, id, type, version, documentToSave } = expectedBulkGetResult.value;
1193+
const {
1194+
esRequestIndex,
1195+
id,
1196+
type,
1197+
version,
1198+
documentToSave,
1199+
objectNamespace,
1200+
} = expectedBulkGetResult.value;
1201+
11861202
let namespaces;
11871203
let versionProperties;
11881204
if (esRequestIndex !== undefined) {
11891205
const indexFound = bulkGetResponse?.statusCode !== 404;
11901206
const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined;
11911207
const docFound = indexFound && actualResult.found === true;
1192-
if (!docFound || !this.rawDocExistsInNamespace(actualResult, namespace)) {
1208+
if (
1209+
!docFound ||
1210+
!this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace))
1211+
) {
11931212
return {
11941213
tag: 'Left' as 'Left',
11951214
error: {
@@ -1205,7 +1224,7 @@ export class SavedObjectsRepository {
12051224
versionProperties = getExpectedVersionProperties(version, actualResult);
12061225
} else {
12071226
if (this._registry.isSingleNamespace(type)) {
1208-
namespaces = [namespaceIdToString(namespace)];
1227+
namespaces = [getNamespaceString(objectNamespace)];
12091228
}
12101229
versionProperties = getExpectedVersionProperties(version);
12111230
}
@@ -1221,7 +1240,7 @@ export class SavedObjectsRepository {
12211240
bulkUpdateParams.push(
12221241
{
12231242
update: {
1224-
_id: this._serializer.generateRawId(namespace, type, id),
1243+
_id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id),
12251244
_index: this.getIndexForType(type),
12261245
...versionProperties,
12271246
},

src/core/server/saved_objects/service/saved_objects_client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ export interface SavedObjectsBulkUpdateObject<T = unknown>
7575
type: string;
7676
/** {@inheritdoc SavedObjectAttributes} */
7777
attributes: Partial<T>;
78+
/**
79+
* Optional namespace string to use for this document. If this is defined, it will supersede the namespace ID that is in
80+
* {@link SavedObjectsUpdateOptions}.
81+
*
82+
* Note: the default namespace's string representation is `'default'`, and its ID representation is `undefined`.
83+
**/
84+
namespace?: string;
7885
}
7986

8087
/**

src/core/server/server.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2079,6 +2079,7 @@ export interface SavedObjectsBulkResponse<T = unknown> {
20792079
export interface SavedObjectsBulkUpdateObject<T = unknown> extends Pick<SavedObjectsUpdateOptions, 'version' | 'references'> {
20802080
attributes: Partial<T>;
20812081
id: string;
2082+
namespace?: string;
20822083
type: string;
20832084
}
20842085

x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,11 @@ const expectSuccess = async (fn: Function, args: Record<string, any>) => {
117117
return result;
118118
};
119119

120-
const expectPrivilegeCheck = async (fn: Function, args: Record<string, any>) => {
120+
const expectPrivilegeCheck = async (
121+
fn: Function,
122+
args: Record<string, any>,
123+
namespacesOverride?: string[]
124+
) => {
121125
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation(
122126
getMockCheckPrivilegesFailure
123127
);
@@ -131,7 +135,7 @@ const expectPrivilegeCheck = async (fn: Function, args: Record<string, any>) =>
131135
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1);
132136
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith(
133137
actions,
134-
args.options?.namespace ?? args.options?.namespaces
138+
namespacesOverride ?? args.options?.namespace ?? args.options?.namespaces
135139
);
136140
};
137141

@@ -468,7 +472,18 @@ describe('#bulkUpdate', () => {
468472

469473
test(`checks privileges for user, actions, and namespace`, async () => {
470474
const objects = [obj1, obj2];
471-
await expectPrivilegeCheck(client.bulkUpdate, { objects, options });
475+
const namespacesOverride = [options.namespace]; // the bulkCreate function checks privileges as an array
476+
await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespacesOverride);
477+
});
478+
479+
test(`checks privileges for object namespaces if present`, async () => {
480+
const objects = [
481+
{ ...obj1, namespace: 'foo-ns' },
482+
{ ...obj2, namespace: 'bar-ns' },
483+
];
484+
const namespacesOverride = ['default', 'foo-ns', 'bar-ns'];
485+
// use the default namespace for the options
486+
await expectPrivilegeCheck(client.bulkUpdate, { objects, options: {} }, namespacesOverride);
472487
});
473488

474489
test(`filters namespaces that the user doesn't have access to`, async () => {

0 commit comments

Comments
 (0)