diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md
index 5f33d62382818..70ad235fb8971 100644
--- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md
+++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md
@@ -8,7 +8,7 @@
Signature:
```typescript
-export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions
+export interface SavedObjectsFindOptions
```
## Properties
@@ -19,6 +19,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions
| [fields](./kibana-plugin-core-public.savedobjectsfindoptions.fields.md) | string[]
| An array of fields to include in the results |
| [filter](./kibana-plugin-core-public.savedobjectsfindoptions.filter.md) | string
| |
| [hasReference](./kibana-plugin-core-public.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
}
| |
+| [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md) | string[]
| |
| [page](./kibana-plugin-core-public.savedobjectsfindoptions.page.md) | number
| |
| [perPage](./kibana-plugin-core-public.savedobjectsfindoptions.perpage.md) | number
| |
| [preference](./kibana-plugin-core-public.savedobjectsfindoptions.preference.md) | string
| An optional ES preference value to be used for the query \* |
diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md
new file mode 100644
index 0000000000000..9cc9d64db1f65
--- /dev/null
+++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md
@@ -0,0 +1,11 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md)
+
+## SavedObjectsFindOptions.namespaces property
+
+Signature:
+
+```typescript
+namespaces?: string[];
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md
index 6db16d979f1fe..67e931f0cb3b3 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md
@@ -8,7 +8,7 @@
Signature:
```typescript
-export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions
+export interface SavedObjectsFindOptions
```
## Properties
@@ -19,6 +19,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions
| [fields](./kibana-plugin-core-server.savedobjectsfindoptions.fields.md) | string[]
| An array of fields to include in the results |
| [filter](./kibana-plugin-core-server.savedobjectsfindoptions.filter.md) | string
| |
| [hasReference](./kibana-plugin-core-server.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
}
| |
+| [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md) | string[]
| |
| [page](./kibana-plugin-core-server.savedobjectsfindoptions.page.md) | number
| |
| [perPage](./kibana-plugin-core-server.savedobjectsfindoptions.perpage.md) | number
| |
| [preference](./kibana-plugin-core-server.savedobjectsfindoptions.preference.md) | string
| An optional ES preference value to be used for the query \* |
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md
new file mode 100644
index 0000000000000..cae707baa58c0
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md
@@ -0,0 +1,11 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md)
+
+## SavedObjectsFindOptions.namespaces property
+
+Signature:
+
+```typescript
+namespaces?: string[];
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md
index 8b89c802ec9ce..6c41441302c0b 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md
@@ -7,14 +7,14 @@
Signature:
```typescript
-find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>;
+find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
-| { search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, } | SavedObjectsFindOptions
| |
+| { search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, } | SavedObjectsFindOptions
| |
Returns:
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md
index b9a92561f29fb..5b02707a3c0f4 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md
@@ -23,7 +23,7 @@ export declare class SavedObjectsRepository
| [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object |
| [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. |
| [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces
\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. |
-| [find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | |
+| [find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | |
| [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object |
| [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. |
| [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object |
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index 303d005197588..c811209dfa80f 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -1282,7 +1282,7 @@ export interface SavedObjectsCreateOptions {
}
// @public (undocumented)
-export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
+export interface SavedObjectsFindOptions {
// (undocumented)
defaultSearchOperator?: 'AND' | 'OR';
fields?: string[];
@@ -1294,6 +1294,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
id: string;
};
// (undocumented)
+ namespaces?: string[];
+ // (undocumented)
page?: number;
// (undocumented)
perPage?: number;
diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts
index c4daaf5d7f307..209f489e29139 100644
--- a/src/core/public/saved_objects/saved_objects_client.ts
+++ b/src/core/public/saved_objects/saved_objects_client.ts
@@ -294,6 +294,7 @@ export class SavedObjectsClient {
sortField: 'sort_field',
type: 'type',
filter: 'filter',
+ namespaces: 'namespaces',
preference: 'preference',
};
diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts
index 5da2235828b5c..27c0a5205ae38 100644
--- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts
+++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts
@@ -107,7 +107,97 @@ describe('getSortedObjectsForExport()', () => {
"calls": Array [
Array [
Object {
- "namespace": undefined,
+ "namespaces": undefined,
+ "perPage": 500,
+ "search": undefined,
+ "type": Array [
+ "index-pattern",
+ "search",
+ ],
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": Promise {},
+ },
+ ],
+ }
+ `);
+ });
+
+ test('omits the `namespaces` property from the export', async () => {
+ savedObjectsClient.find.mockResolvedValueOnce({
+ total: 2,
+ saved_objects: [
+ {
+ id: '2',
+ type: 'search',
+ attributes: {},
+ namespaces: ['foo', 'bar'],
+ score: 0,
+ references: [
+ {
+ name: 'name',
+ type: 'index-pattern',
+ id: '1',
+ },
+ ],
+ },
+ {
+ id: '1',
+ type: 'index-pattern',
+ attributes: {},
+ namespaces: ['foo', 'bar'],
+ score: 0,
+ references: [],
+ },
+ ],
+ per_page: 1,
+ page: 0,
+ });
+ const exportStream = await exportSavedObjectsToStream({
+ savedObjectsClient,
+ exportSizeLimit: 500,
+ types: ['index-pattern', 'search'],
+ });
+
+ const response = await readStreamToCompletion(exportStream);
+
+ expect(response).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "attributes": Object {},
+ "id": "1",
+ "references": Array [],
+ "type": "index-pattern",
+ },
+ Object {
+ "attributes": Object {},
+ "id": "2",
+ "references": Array [
+ Object {
+ "id": "1",
+ "name": "name",
+ "type": "index-pattern",
+ },
+ ],
+ "type": "search",
+ },
+ Object {
+ "exportedCount": 2,
+ "missingRefCount": 0,
+ "missingReferences": Array [],
+ },
+ ]
+ `);
+ expect(savedObjectsClient.find).toMatchInlineSnapshot(`
+ [MockFunction] {
+ "calls": Array [
+ Array [
+ Object {
+ "namespaces": undefined,
"perPage": 500,
"search": undefined,
"type": Array [
@@ -257,7 +347,7 @@ describe('getSortedObjectsForExport()', () => {
"calls": Array [
Array [
Object {
- "namespace": undefined,
+ "namespaces": undefined,
"perPage": 500,
"search": "foo",
"type": Array [
@@ -346,7 +436,9 @@ describe('getSortedObjectsForExport()', () => {
"calls": Array [
Array [
Object {
- "namespace": "foo",
+ "namespaces": Array [
+ "foo",
+ ],
"perPage": 500,
"search": undefined,
"type": Array [
diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts
index 6e985c25aeaef..6cfe6f1be5669 100644
--- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts
+++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts
@@ -109,7 +109,7 @@ async function fetchObjectsToExport({
type: types,
search,
perPage: exportSizeLimit,
- namespace,
+ namespaces: namespace ? [namespace] : undefined,
});
if (findResponse.total > exportSizeLimit) {
throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`);
@@ -162,10 +162,15 @@ export async function exportSavedObjectsToStream({
exportedObjects = sortObjects(rootObjects);
}
+ // redact attributes that should not be exported
+ const redactedObjects = exportedObjects.map>(
+ ({ namespaces, ...object }) => object
+ );
+
const exportDetails: SavedObjectsExportResultDetails = {
exportedCount: exportedObjects.length,
missingRefCount: missingReferences.length,
missingReferences,
};
- return createListStream([...exportedObjects, ...(excludeExportDetails ? [] : [exportDetails])]);
+ return createListStream([...redactedObjects, ...(excludeExportDetails ? [] : [exportDetails])]);
}
diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts
index 5c1c2c9a9ab87..6313a95b1fefa 100644
--- a/src/core/server/saved_objects/routes/find.ts
+++ b/src/core/server/saved_objects/routes/find.ts
@@ -45,11 +45,18 @@ export const registerFindRoute = (router: IRouter) => {
),
fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])),
filter: schema.maybe(schema.string()),
+ namespaces: schema.maybe(
+ schema.oneOf([schema.string(), schema.arrayOf(schema.string())])
+ ),
}),
},
},
router.handleLegacyErrors(async (context, req, res) => {
const query = req.query;
+
+ const namespaces =
+ typeof req.query.namespaces === 'string' ? [req.query.namespaces] : req.query.namespaces;
+
const result = await context.core.savedObjects.client.find({
perPage: query.per_page,
page: query.page,
@@ -62,6 +69,7 @@ export const registerFindRoute = (router: IRouter) => {
hasReference: query.has_reference,
fields: typeof query.fields === 'string' ? [query.fields] : query.fields,
filter: query.filter,
+ namespaces,
});
return res.ok({ body: result });
diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts
index 33e12dd4e517d..d5a7710f04b39 100644
--- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts
+++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts
@@ -81,6 +81,7 @@ describe('GET /api/saved_objects/_find', () => {
attributes: {},
score: 1,
references: [],
+ namespaces: ['default'],
},
{
type: 'index-pattern',
@@ -91,6 +92,7 @@ describe('GET /api/saved_objects/_find', () => {
attributes: {},
score: 1,
references: [],
+ namespaces: ['default'],
},
],
};
@@ -241,4 +243,38 @@ describe('GET /api/saved_objects/_find', () => {
defaultSearchOperator: 'OR',
});
});
+
+ it('accepts the query parameter namespaces as a string', async () => {
+ await supertest(httpSetup.server.listener)
+ .get('/api/saved_objects/_find?type=index-pattern&namespaces=foo')
+ .expect(200);
+
+ expect(savedObjectsClient.find).toHaveBeenCalledTimes(1);
+
+ const options = savedObjectsClient.find.mock.calls[0][0];
+ expect(options).toEqual({
+ perPage: 20,
+ page: 1,
+ type: ['index-pattern'],
+ namespaces: ['foo'],
+ defaultSearchOperator: 'OR',
+ });
+ });
+
+ it('accepts the query parameter namespaces as an array', async () => {
+ await supertest(httpSetup.server.listener)
+ .get('/api/saved_objects/_find?type=index-pattern&namespaces=default&namespaces=foo')
+ .expect(200);
+
+ expect(savedObjectsClient.find).toHaveBeenCalledTimes(1);
+
+ const options = savedObjectsClient.find.mock.calls[0][0];
+ expect(options).toEqual({
+ perPage: 20,
+ page: 1,
+ type: ['index-pattern'],
+ namespaces: ['default', 'foo'],
+ defaultSearchOperator: 'OR',
+ });
+ });
});
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 ea749235cbb41..d563edbe66c9b 100644
--- a/src/core/server/saved_objects/service/lib/repository.test.js
+++ b/src/core/server/saved_objects/service/lib/repository.test.js
@@ -494,6 +494,7 @@ describe('SavedObjectsRepository', () => {
...obj,
migrationVersion: { [obj.type]: '1.1.1' },
version: mockVersion,
+ namespaces: obj.namespaces ?? [obj.namespace ?? 'default'],
...mockTimestampFields,
});
@@ -826,9 +827,19 @@ describe('SavedObjectsRepository', () => {
// Assert that both raw docs from the ES response are deserialized
expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(1, {
...response.items[0].create,
+ _source: {
+ ...response.items[0].create._source,
+ namespaces: response.items[0].create._source.namespaces,
+ },
_id: expect.stringMatching(/^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/),
});
- expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(2, response.items[1].create);
+ expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(2, {
+ ...response.items[1].create,
+ _source: {
+ ...response.items[1].create._source,
+ namespaces: response.items[1].create._source.namespaces,
+ },
+ });
// Assert that ID's are deserialized to remove the type and namespace
expect(result.saved_objects[0].id).toEqual(
@@ -985,7 +996,7 @@ describe('SavedObjectsRepository', () => {
const expectSuccessResult = ({ type, id }, doc) => ({
type,
id,
- ...(doc._source.namespaces && { namespaces: doc._source.namespaces }),
+ namespaces: doc._source.namespaces ?? ['default'],
...(doc._source.updated_at && { updated_at: doc._source.updated_at }),
version: encodeHitVersion(doc),
attributes: doc._source[type],
@@ -1027,12 +1038,12 @@ describe('SavedObjectsRepository', () => {
});
});
- it(`includes namespaces property for multi-namespace documents`, async () => {
+ it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => {
const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' };
const result = await bulkGetSuccess([obj1, obj]);
expect(result).toEqual({
saved_objects: [
- expect.not.objectContaining({ namespaces: expect.anything() }),
+ expect.objectContaining({ namespaces: ['default'] }),
expect.objectContaining({ namespaces: expect.any(Array) }),
],
});
@@ -1350,12 +1361,13 @@ describe('SavedObjectsRepository', () => {
});
describe('returns', () => {
- const expectSuccessResult = ({ type, id, attributes, references }) => ({
+ const expectSuccessResult = ({ type, id, attributes, references, namespaces }) => ({
type,
id,
attributes,
references,
version: mockVersion,
+ namespaces: namespaces ?? ['default'],
...mockTimestampFields,
});
@@ -1389,12 +1401,12 @@ describe('SavedObjectsRepository', () => {
});
});
- it(`includes namespaces property for multi-namespace documents`, async () => {
+ it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => {
const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' };
const result = await bulkUpdateSuccess([obj1, obj]);
expect(result).toEqual({
saved_objects: [
- expect.not.objectContaining({ namespaces: expect.anything() }),
+ expect.objectContaining({ namespaces: expect.any(Array) }),
expect.objectContaining({ namespaces: expect.any(Array) }),
],
});
@@ -1651,6 +1663,7 @@ describe('SavedObjectsRepository', () => {
version: mockVersion,
attributes,
references,
+ namespaces: [namespace ?? 'default'],
migrationVersion: { [type]: '1.1.1' },
});
});
@@ -1907,7 +1920,7 @@ describe('SavedObjectsRepository', () => {
await deleteByNamespaceSuccess(namespace);
const allTypes = registry.getAllTypes().map((type) => type.name);
expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, {
- namespace,
+ namespaces: [namespace],
type: allTypes.filter((type) => !registry.isNamespaceAgnostic(type)),
});
});
@@ -2134,6 +2147,7 @@ describe('SavedObjectsRepository', () => {
score: doc._score,
attributes: doc._source[doc._source.type],
references: [],
+ namespaces: doc._source.type === NAMESPACE_AGNOSTIC_TYPE ? undefined : ['default'],
});
});
});
@@ -2143,7 +2157,7 @@ describe('SavedObjectsRepository', () => {
callAdminCluster.mockReturnValue(namespacedSearchResults);
const count = namespacedSearchResults.hits.hits.length;
- const response = await savedObjectsRepository.find({ type, namespace });
+ const response = await savedObjectsRepository.find({ type, namespaces: [namespace] });
expect(response.total).toBe(count);
expect(response.saved_objects).toHaveLength(count);
@@ -2157,6 +2171,7 @@ describe('SavedObjectsRepository', () => {
score: doc._score,
attributes: doc._source[doc._source.type],
references: [],
+ namespaces: doc._source.type === NAMESPACE_AGNOSTIC_TYPE ? undefined : [namespace],
});
});
});
@@ -2176,7 +2191,7 @@ describe('SavedObjectsRepository', () => {
describe('search dsl', () => {
it(`passes mappings, registry, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl`, async () => {
const relevantOpts = {
- namespace,
+ namespaces: [namespace],
search: 'foo*',
searchFields: ['foo'],
type: [type],
@@ -2374,6 +2389,7 @@ describe('SavedObjectsRepository', () => {
title: 'Testing',
},
references: [],
+ namespaces: ['default'],
});
});
@@ -2384,10 +2400,10 @@ describe('SavedObjectsRepository', () => {
});
});
- it(`doesn't include namespaces if type is not multi-namespace`, async () => {
+ it(`include namespaces if type is not multi-namespace`, async () => {
const result = await getSuccess(type, id);
- expect(result).not.toMatchObject({
- namespaces: expect.anything(),
+ expect(result).toMatchObject({
+ namespaces: ['default'],
});
});
});
@@ -2908,10 +2924,10 @@ describe('SavedObjectsRepository', () => {
_id: `${type}:${id}`,
...mockVersionProps,
result: 'updated',
- ...(registry.isMultiNamespace(type) && {
- // don't need the rest of the source for test purposes, just the namespaces attribute
- get: { _source: { namespaces: [options?.namespace ?? 'default'] } },
- }),
+ // don't need the rest of the source for test purposes, just the namespace and namespaces attributes
+ get: {
+ _source: { namespaces: [options?.namespace ?? 'default'], namespace: options?.namespace },
+ },
}); // this._writeToCluster('update', ...)
const result = await savedObjectsRepository.update(type, id, attributes, options);
expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1);
@@ -3011,15 +3027,15 @@ describe('SavedObjectsRepository', () => {
it(`includes _sourceIncludes when type is multi-namespace`, async () => {
await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes);
- expectClusterCallArgs({ _sourceIncludes: ['namespaces'] }, 2);
+ expectClusterCallArgs({ _sourceIncludes: ['namespace', 'namespaces'] }, 2);
});
- it(`doesn't include _sourceIncludes when type is not multi-namespace`, async () => {
+ it(`includes _sourceIncludes when type is not multi-namespace`, async () => {
await updateSuccess(type, id, attributes);
expect(callAdminCluster).toHaveBeenLastCalledWith(
expect.any(String),
- expect.not.objectContaining({
- _sourceIncludes: expect.anything(),
+ expect.objectContaining({
+ _sourceIncludes: ['namespace', 'namespaces'],
})
);
});
@@ -3093,6 +3109,7 @@ describe('SavedObjectsRepository', () => {
version: mockVersion,
attributes,
references,
+ namespaces: [namespace],
});
});
@@ -3103,10 +3120,10 @@ describe('SavedObjectsRepository', () => {
});
});
- it(`doesn't include namespaces if type is not multi-namespace`, async () => {
+ it(`includes namespaces if type is not multi-namespace`, async () => {
const result = await updateSuccess(type, id, attributes);
- expect(result).not.toMatchObject({
- namespaces: expect.anything(),
+ expect(result).toMatchObject({
+ namespaces: ['default'],
});
});
});
diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts
index 880b71e164b5b..7a5ac9204627c 100644
--- a/src/core/server/saved_objects/service/lib/repository.ts
+++ b/src/core/server/saved_objects/service/lib/repository.ts
@@ -423,7 +423,7 @@ export class SavedObjectsRepository {
// When method == 'index' the bulkResponse doesn't include the indexed
// _source so we return rawMigratedDoc but have to spread the latest
// _seq_no and _primary_term values from the rawResponse.
- return this._serializer.rawToSavedObject({
+ return this._rawToSavedObject({
...rawMigratedDoc,
...{ _seq_no: rawResponse._seq_no, _primary_term: rawResponse._primary_term },
});
@@ -554,7 +554,7 @@ export class SavedObjectsRepository {
},
conflicts: 'proceed',
...getSearchDsl(this._mappings, this._registry, {
- namespace,
+ namespaces: namespace ? [namespace] : undefined,
type: typesToUpdate,
}),
},
@@ -590,7 +590,7 @@ export class SavedObjectsRepository {
sortField,
sortOrder,
fields,
- namespace,
+ namespaces,
type,
filter,
preference,
@@ -651,7 +651,7 @@ export class SavedObjectsRepository {
type: allowedTypes,
sortField,
sortOrder,
- namespace,
+ namespaces,
hasReference,
kueryNode,
}),
@@ -768,10 +768,16 @@ export class SavedObjectsRepository {
}
const time = doc._source.updated_at;
+
+ let namespaces = [];
+ if (!this._registry.isNamespaceAgnostic(type)) {
+ namespaces = doc._source.namespaces ?? [getNamespaceString(doc._source.namespace)];
+ }
+
return {
id,
type,
- ...(doc._source.namespaces && { namespaces: doc._source.namespaces }),
+ namespaces,
...(time && { updated_at: time }),
version: encodeHitVersion(doc),
attributes: doc._source[type],
@@ -817,10 +823,15 @@ export class SavedObjectsRepository {
const { updated_at: updatedAt } = response._source;
+ let namespaces = [];
+ if (!this._registry.isNamespaceAgnostic(type)) {
+ namespaces = response._source.namespaces ?? [getNamespaceString(response._source.namespace)];
+ }
+
return {
id,
type,
- ...(response._source.namespaces && { namespaces: response._source.namespaces }),
+ namespaces,
...(updatedAt && { updated_at: updatedAt }),
version: encodeHitVersion(response),
attributes: response._source[type],
@@ -874,7 +885,7 @@ export class SavedObjectsRepository {
body: {
doc,
},
- ...(this._registry.isMultiNamespace(type) && { _sourceIncludes: ['namespaces'] }),
+ _sourceIncludes: ['namespace', 'namespaces'],
});
if (updateResponse.status === 404) {
@@ -882,14 +893,19 @@ export class SavedObjectsRepository {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
+ let namespaces = [];
+ if (!this._registry.isNamespaceAgnostic(type)) {
+ namespaces = updateResponse.get._source.namespaces ?? [
+ getNamespaceString(updateResponse.get._source.namespace),
+ ];
+ }
+
return {
id,
type,
updated_at: time,
version: encodeHitVersion(updateResponse),
- ...(this._registry.isMultiNamespace(type) && {
- namespaces: updateResponse.get._source.namespaces,
- }),
+ namespaces,
references,
attributes,
};
@@ -1142,9 +1158,14 @@ export class SavedObjectsRepository {
},
};
}
- namespaces = actualResult._source.namespaces;
+ namespaces = actualResult._source.namespaces ?? [
+ getNamespaceString(actualResult._source.namespace),
+ ];
versionProperties = getExpectedVersionProperties(version, actualResult);
} else {
+ if (this._registry.isSingleNamespace(type)) {
+ namespaces = [getNamespaceString(namespace)];
+ }
versionProperties = getExpectedVersionProperties(version);
}
@@ -1340,12 +1361,12 @@ export class SavedObjectsRepository {
return new Date().toISOString();
}
- // The internal representation of the saved object that the serializer returns
- // includes the namespace, and we use this for migrating documents. However, we don't
- // want the namespace to be returned from the repository, as the repository scopes each
- // method transparently to the specified namespace.
private _rawToSavedObject(raw: SavedObjectsRawDoc): SavedObject {
const savedObject = this._serializer.rawToSavedObject(raw);
+ const { namespace, type } = savedObject;
+ if (this._registry.isSingleNamespace(type)) {
+ savedObject.namespaces = [getNamespaceString(namespace)];
+ }
return omit(savedObject, 'namespace') as SavedObject;
}
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 a0ffa91f53671..f916638c5251b 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
@@ -196,19 +196,29 @@ describe('#getQueryParams', () => {
});
});
- describe('`namespace` parameter', () => {
- const createTypeClause = (type: string, namespace?: string) => {
+ describe('`namespaces` parameter', () => {
+ const createTypeClause = (type: string, namespaces?: string[]) => {
if (registry.isMultiNamespace(type)) {
return {
bool: {
- must: expect.arrayContaining([{ term: { namespaces: namespace ?? 'default' } }]),
+ must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]),
must_not: [{ exists: { field: 'namespace' } }],
},
};
- } else if (namespace && registry.isSingleNamespace(type)) {
+ } else if (registry.isSingleNamespace(type)) {
+ const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? [];
+ const should: any = [];
+ if (nonDefaultNamespaces.length > 0) {
+ should.push({ terms: { namespace: nonDefaultNamespaces } });
+ }
+ if (namespaces?.includes('default')) {
+ should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } });
+ }
return {
bool: {
- must: expect.arrayContaining([{ term: { namespace } }]),
+ must: [{ term: { type } }],
+ should: expect.arrayContaining(should),
+ minimum_should_match: 1,
must_not: [{ exists: { field: 'namespaces' } }],
},
};
@@ -229,23 +239,45 @@ describe('#getQueryParams', () => {
);
};
- const test = (namespace?: string) => {
+ const test = (namespaces?: string[]) => {
for (const typeOrTypes of ALL_TYPE_SUBSETS) {
- const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespace });
+ const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespaces });
const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes];
- expectResult(result, ...types.map((x) => createTypeClause(x, namespace)));
+ expectResult(result, ...types.map((x) => createTypeClause(x, namespaces)));
}
// also test with no specified type/s
- const result = getQueryParams({ mappings, registry, type: undefined, namespace });
- expectResult(result, ...ALL_TYPES.map((x) => createTypeClause(x, namespace)));
+ const result = getQueryParams({ mappings, registry, type: undefined, namespaces });
+ expectResult(result, ...ALL_TYPES.map((x) => createTypeClause(x, namespaces)));
};
- it('filters results with "namespace" field when `namespace` is not specified', () => {
+ it('normalizes and deduplicates provided namespaces', () => {
+ const result = getQueryParams({
+ mappings,
+ registry,
+ search: '*',
+ namespaces: ['foo', '*', 'foo', 'bar', 'default'],
+ });
+
+ expectResult(
+ result,
+ ...ALL_TYPES.map((x) => createTypeClause(x, ['foo', 'default', 'bar']))
+ );
+ });
+
+ it('filters results with "namespace" field when `namespaces` is not specified', () => {
test(undefined);
});
it('filters results for specified namespace for appropriate type/s', () => {
- test('foo-namespace');
+ test(['foo-namespace']);
+ });
+
+ it('filters results for specified namespaces for appropriate type/s', () => {
+ test(['foo-namespace', 'default']);
+ });
+
+ it('filters results for specified `default` namespace for appropriate type/s', () => {
+ test(['default']);
});
});
});
@@ -353,4 +385,18 @@ describe('#getQueryParams', () => {
});
});
});
+
+ describe('namespaces property', () => {
+ ALL_TYPES.forEach((type) => {
+ it(`throws for ${type} when namespaces is an empty array`, () => {
+ expect(() =>
+ getQueryParams({
+ mappings,
+ registry,
+ namespaces: [],
+ })
+ ).toThrowError('cannot specify empty namespaces array');
+ });
+ });
+ });
});
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 40485564176a6..164756f9796a5 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
@@ -63,25 +63,42 @@ function getFieldsForTypes(types: string[], searchFields?: string[]) {
*/
function getClauseForType(
registry: ISavedObjectTypeRegistry,
- namespace: string | undefined,
+ namespaces: string[] = ['default'],
type: string
) {
+ if (namespaces.length === 0) {
+ throw new Error('cannot specify empty namespaces array');
+ }
if (registry.isMultiNamespace(type)) {
return {
bool: {
- must: [{ term: { type } }, { term: { namespaces: namespace ?? 'default' } }],
+ must: [{ term: { type } }, { terms: { namespaces } }],
must_not: [{ exists: { field: 'namespace' } }],
},
};
- } else if (namespace && registry.isSingleNamespace(type)) {
+ } else if (registry.isSingleNamespace(type)) {
+ const should: Array> = [];
+ const eligibleNamespaces = namespaces.filter((namespace) => namespace !== 'default');
+ if (eligibleNamespaces.length > 0) {
+ should.push({ terms: { namespace: eligibleNamespaces } });
+ }
+ if (namespaces.includes('default')) {
+ should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } });
+ }
+ if (should.length === 0) {
+ // This is indicitive of a bug, and not user error.
+ throw new Error('unhandled search condition: expected at least 1 `should` clause.');
+ }
return {
bool: {
- must: [{ term: { type } }, { term: { namespace } }],
+ must: [{ term: { type } }],
+ should,
+ minimum_should_match: 1,
must_not: [{ exists: { field: 'namespaces' } }],
},
};
}
- // isSingleNamespace in the default namespace, or isNamespaceAgnostic
+ // isNamespaceAgnostic
return {
bool: {
must: [{ term: { type } }],
@@ -98,7 +115,7 @@ interface HasReferenceQueryParams {
interface QueryParams {
mappings: IndexMapping;
registry: ISavedObjectTypeRegistry;
- namespace?: string;
+ namespaces?: string[];
type?: string | string[];
search?: string;
searchFields?: string[];
@@ -113,7 +130,7 @@ interface QueryParams {
export function getQueryParams({
mappings,
registry,
- namespace,
+ namespaces,
type,
search,
searchFields,
@@ -122,6 +139,22 @@ export function getQueryParams({
kueryNode,
}: QueryParams) {
const types = getTypes(mappings, type);
+
+ // A de-duplicated set of namespaces makes for a more effecient query.
+ //
+ // Additonally, we treat the `*` namespace as the `default` namespace.
+ // In the Default Distribution, the `*` is automatically expanded to include all available namespaces.
+ // However, the OSS distribution (and certain configurations of the Default Distribution) can allow the `*`
+ // to pass through to the SO Repository, and eventually to this module. When this happens, we translate to `default`,
+ // since that is consistent with how a single-namespace search behaves in the OSS distribution. Leaving the wildcard in place
+ // would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard.
+ // We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716
+ const normalizedNamespaces = namespaces
+ ? Array.from(
+ new Set(namespaces.map((namespace) => (namespace === '*' ? 'default' : namespace)))
+ )
+ : undefined;
+
const bool: any = {
filter: [
...(kueryNode != null ? [esKuery.toElasticsearchQuery(kueryNode)] : []),
@@ -152,7 +185,9 @@ export function getQueryParams({
},
]
: undefined,
- should: types.map((shouldType) => getClauseForType(registry, namespace, shouldType)),
+ should: types.map((shouldType) =>
+ getClauseForType(registry, normalizedNamespaces, shouldType)
+ ),
minimum_should_match: 1,
},
},
diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts
index 95b7ffd117ee9..08ad72397e4a2 100644
--- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts
+++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts
@@ -57,9 +57,9 @@ describe('getSearchDsl', () => {
});
describe('passes control', () => {
- it('passes (mappings, schema, namespace, type, search, searchFields, hasReference) to getQueryParams', () => {
+ it('passes (mappings, schema, namespaces, type, search, searchFields, hasReference) to getQueryParams', () => {
const opts = {
- namespace: 'foo-namespace',
+ namespaces: ['foo-namespace'],
type: 'foo',
search: 'bar',
searchFields: ['baz'],
@@ -75,7 +75,7 @@ describe('getSearchDsl', () => {
expect(getQueryParams).toHaveBeenCalledWith({
mappings,
registry,
- namespace: opts.namespace,
+ namespaces: opts.namespaces,
type: opts.type,
search: opts.search,
searchFields: opts.searchFields,
diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts
index 74c25491aff8b..6de868c320240 100644
--- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts
+++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts
@@ -33,7 +33,7 @@ interface GetSearchDslOptions {
searchFields?: string[];
sortField?: string;
sortOrder?: string;
- namespace?: string;
+ namespaces?: string[];
hasReference?: {
type: string;
id: string;
@@ -53,7 +53,7 @@ export function getSearchDsl(
searchFields,
sortField,
sortOrder,
- namespace,
+ namespaces,
hasReference,
kueryNode,
} = options;
@@ -70,7 +70,7 @@ export function getSearchDsl(
...getQueryParams({
mappings,
registry,
- namespace,
+ namespaces,
type,
search,
searchFields,
diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts
index 2183b47b732f9..f9301d6598b1d 100644
--- a/src/core/server/saved_objects/types.ts
+++ b/src/core/server/saved_objects/types.ts
@@ -63,7 +63,7 @@ export interface SavedObjectStatusMeta {
*
* @public
*/
-export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
+export interface SavedObjectsFindOptions {
type: string | string[];
page?: number;
perPage?: number;
@@ -82,6 +82,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
hasReference?: { type: string; id: string };
defaultSearchOperator?: 'AND' | 'OR';
filter?: string;
+ namespaces?: string[];
/** An optional ES preference value to be used for the query **/
preference?: string;
}
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 886544a4df317..a0e16602ba4bf 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -2175,7 +2175,7 @@ export interface SavedObjectsExportResultDetails {
export type SavedObjectsFieldMapping = SavedObjectsCoreFieldMapping | SavedObjectsComplexFieldMapping;
// @public (undocumented)
-export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
+export interface SavedObjectsFindOptions {
// (undocumented)
defaultSearchOperator?: 'AND' | 'OR';
fields?: string[];
@@ -2187,6 +2187,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions {
id: string;
};
// (undocumented)
+ namespaces?: string[];
+ // (undocumented)
page?: number;
// (undocumented)
perPage?: number;
@@ -2398,7 +2400,7 @@ export class SavedObjectsRepository {
deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise;
deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>;
// (undocumented)
- find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>;
+ find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>;
get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>;
incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise<{
id: string;
diff --git a/test/api_integration/apis/saved_objects/bulk_create.js b/test/api_integration/apis/saved_objects/bulk_create.js
index 6cb9d5dccdc9a..7db968df8357a 100644
--- a/test/api_integration/apis/saved_objects/bulk_create.js
+++ b/test/api_integration/apis/saved_objects/bulk_create.js
@@ -76,6 +76,7 @@ export default function ({ getService }) {
dashboard: resp.body.saved_objects[1].migrationVersion.dashboard,
},
references: [],
+ namespaces: ['default'],
},
],
});
@@ -121,6 +122,7 @@ export default function ({ getService }) {
title: 'An existing visualization',
},
references: [],
+ namespaces: ['default'],
migrationVersion: {
visualization: resp.body.saved_objects[0].migrationVersion.visualization,
},
@@ -134,6 +136,7 @@ export default function ({ getService }) {
title: 'A great new dashboard',
},
references: [],
+ namespaces: ['default'],
migrationVersion: {
dashboard: resp.body.saved_objects[1].migrationVersion.dashboard,
},
diff --git a/test/api_integration/apis/saved_objects/bulk_get.js b/test/api_integration/apis/saved_objects/bulk_get.js
index c802d52913065..56ee5a69be23e 100644
--- a/test/api_integration/apis/saved_objects/bulk_get.js
+++ b/test/api_integration/apis/saved_objects/bulk_get.js
@@ -68,6 +68,7 @@ export default function ({ getService }) {
resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta,
},
migrationVersion: resp.body.saved_objects[0].migrationVersion,
+ namespaces: ['default'],
references: [
{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
@@ -94,6 +95,7 @@ export default function ({ getService }) {
buildNum: 8467,
defaultIndex: '91200a00-9efd-11e7-acb3-3dab96693fab',
},
+ namespaces: ['default'],
migrationVersion: resp.body.saved_objects[2].migrationVersion,
references: [],
},
diff --git a/test/api_integration/apis/saved_objects/bulk_update.js b/test/api_integration/apis/saved_objects/bulk_update.js
index e3f994ff224e8..973ce382ea813 100644
--- a/test/api_integration/apis/saved_objects/bulk_update.js
+++ b/test/api_integration/apis/saved_objects/bulk_update.js
@@ -65,6 +65,7 @@ export default function ({ getService }) {
attributes: {
title: 'An existing visualization',
},
+ namespaces: ['default'],
});
expect(secondObject)
@@ -77,6 +78,7 @@ export default function ({ getService }) {
attributes: {
title: 'An existing dashboard',
},
+ namespaces: ['default'],
});
});
@@ -233,6 +235,7 @@ export default function ({ getService }) {
attributes: {
title: 'An existing dashboard',
},
+ namespaces: ['default'],
});
});
});
diff --git a/test/api_integration/apis/saved_objects/create.js b/test/api_integration/apis/saved_objects/create.js
index eddda3aded141..c1300125441bc 100644
--- a/test/api_integration/apis/saved_objects/create.js
+++ b/test/api_integration/apis/saved_objects/create.js
@@ -58,6 +58,7 @@ export default function ({ getService }) {
title: 'My favorite vis',
},
references: [],
+ namespaces: ['default'],
});
expect(resp.body.migrationVersion).to.be.ok();
});
@@ -104,6 +105,7 @@ export default function ({ getService }) {
title: 'My favorite vis',
},
references: [],
+ namespaces: ['default'],
});
expect(resp.body.migrationVersion).to.be.ok();
});
diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js
index 7cb5955e4a43d..f129bf22840da 100644
--- a/test/api_integration/apis/saved_objects/find.js
+++ b/test/api_integration/apis/saved_objects/find.js
@@ -48,6 +48,7 @@ export default function ({ getService }) {
},
score: 0,
migrationVersion: resp.body.saved_objects[0].migrationVersion,
+ namespaces: ['default'],
references: [
{
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
@@ -107,6 +108,93 @@ export default function ({ getService }) {
}));
});
+ describe('unknown namespace', () => {
+ it('should return 200 with empty response', async () =>
+ await supertest
+ .get('/api/saved_objects/_find?type=visualization&namespaces=foo')
+ .expect(200)
+ .then((resp) => {
+ expect(resp.body).to.eql({
+ page: 1,
+ per_page: 20,
+ total: 0,
+ saved_objects: [],
+ });
+ }));
+ });
+
+ describe('known namespace', () => {
+ it('should return 200 with individual responses', async () =>
+ await supertest
+ .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=default')
+ .expect(200)
+ .then((resp) => {
+ expect(resp.body).to.eql({
+ page: 1,
+ per_page: 20,
+ total: 1,
+ saved_objects: [
+ {
+ type: 'visualization',
+ id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
+ version: 'WzIsMV0=',
+ attributes: {
+ title: 'Count of requests',
+ },
+ migrationVersion: resp.body.saved_objects[0].migrationVersion,
+ namespaces: ['default'],
+ score: 0,
+ references: [
+ {
+ id: '91200a00-9efd-11e7-acb3-3dab96693fab',
+ name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
+ type: 'index-pattern',
+ },
+ ],
+ updated_at: '2017-09-21T18:51:23.794Z',
+ },
+ ],
+ });
+ expect(resp.body.saved_objects[0].migrationVersion).to.be.ok();
+ }));
+ });
+
+ describe('wildcard namespace', () => {
+ it('should return 200 with individual responses from the default namespace', async () =>
+ await supertest
+ .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=*')
+ .expect(200)
+ .then((resp) => {
+ expect(resp.body).to.eql({
+ page: 1,
+ per_page: 20,
+ total: 1,
+ saved_objects: [
+ {
+ type: 'visualization',
+ id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
+ version: 'WzIsMV0=',
+ attributes: {
+ title: 'Count of requests',
+ },
+ migrationVersion: resp.body.saved_objects[0].migrationVersion,
+ namespaces: ['default'],
+ score: 0,
+ references: [
+ {
+ id: '91200a00-9efd-11e7-acb3-3dab96693fab',
+ name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
+ type: 'index-pattern',
+ },
+ ],
+ updated_at: '2017-09-21T18:51:23.794Z',
+ },
+ ],
+ });
+ expect(resp.body.saved_objects[0].migrationVersion).to.be.ok();
+ }));
+ });
+
describe('with a filter', () => {
it('should return 200 with a valid response', async () =>
await supertest
@@ -135,6 +223,7 @@ export default function ({ getService }) {
.searchSourceJSON,
},
},
+ namespaces: ['default'],
score: 0,
references: [
{
diff --git a/test/api_integration/apis/saved_objects/get.js b/test/api_integration/apis/saved_objects/get.js
index 55dfda251a75a..6bb5cf0c8a7ff 100644
--- a/test/api_integration/apis/saved_objects/get.js
+++ b/test/api_integration/apis/saved_objects/get.js
@@ -56,6 +56,7 @@ export default function ({ getService }) {
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
},
],
+ namespaces: ['default'],
});
expect(resp.body.migrationVersion).to.be.ok();
}));
diff --git a/test/api_integration/apis/saved_objects/update.js b/test/api_integration/apis/saved_objects/update.js
index d613f46878bb5..7803c39897f28 100644
--- a/test/api_integration/apis/saved_objects/update.js
+++ b/test/api_integration/apis/saved_objects/update.js
@@ -56,6 +56,7 @@ export default function ({ getService }) {
attributes: {
title: 'My second favorite vis',
},
+ namespaces: ['default'],
});
});
});
diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts
index b5154d619685a..08c4327d7c0c4 100644
--- a/test/api_integration/apis/saved_objects_management/find.ts
+++ b/test/api_integration/apis/saved_objects_management/find.ts
@@ -49,6 +49,7 @@ export default function ({ getService }: FtrProviderContext) {
title: 'Count of requests',
},
migrationVersion: resp.body.saved_objects[0].migrationVersion,
+ namespaces: ['default'],
references: [
{
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts
index eea19bb1aa7dd..5d4ea5a6370e4 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts
@@ -939,6 +939,7 @@ describe('#bulkGet', () => {
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
},
+ namespaces: ['some-ns'],
references: [],
},
{
@@ -950,6 +951,7 @@ describe('#bulkGet', () => {
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
+ namespaces: ['some-ns'],
references: [],
},
],
@@ -1015,6 +1017,7 @@ describe('#bulkGet', () => {
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
},
+ namespaces: ['some-ns'],
references: [],
},
{
@@ -1026,6 +1029,7 @@ describe('#bulkGet', () => {
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
+ namespaces: ['some-ns'],
references: [],
},
],
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts
index bdc2b6cb2e667..3246457179f68 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts
@@ -25,6 +25,7 @@ import {
} from 'src/core/server';
import { AuthenticatedUser } from '../../../security/common/model';
import { EncryptedSavedObjectsService } from '../crypto';
+import { getDescriptorNamespace } from './get_descriptor_namespace';
interface EncryptedSavedObjectsClientOptions {
baseClient: SavedObjectsClientContract;
@@ -47,10 +48,6 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
public readonly errors = options.baseClient.errors
) {}
- // only include namespace in AAD descriptor if the specified type is single-namespace
- private getDescriptorNamespace = (type: string, namespace?: string) =>
- this.options.baseTypeRegistry.isSingleNamespace(type) ? namespace : undefined;
-
public async create(
type: string,
attributes: T = {} as T,
@@ -70,7 +67,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
}
const id = generateID();
- const namespace = this.getDescriptorNamespace(type, options.namespace);
+ const namespace = getDescriptorNamespace(
+ this.options.baseTypeRegistry,
+ type,
+ options.namespace
+ );
return await this.handleEncryptedAttributesInResponse(
await this.options.baseClient.create(
type,
@@ -109,7 +110,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
}
const id = generateID();
- const namespace = this.getDescriptorNamespace(object.type, options?.namespace);
+ const namespace = getDescriptorNamespace(
+ this.options.baseTypeRegistry,
+ object.type,
+ options?.namespace
+ );
return {
...object,
id,
@@ -124,8 +129,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.bulkCreate(encryptedObjects, options),
- objects,
- options?.namespace
+ objects
);
}
@@ -142,7 +146,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
if (!this.options.service.isRegistered(type)) {
return object;
}
- const namespace = this.getDescriptorNamespace(type, options?.namespace);
+ const namespace = getDescriptorNamespace(
+ this.options.baseTypeRegistry,
+ type,
+ options?.namespace
+ );
return {
...object,
attributes: await this.options.service.encryptAttributes(
@@ -156,8 +164,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.bulkUpdate(encryptedObjects, options),
- objects,
- options?.namespace
+ objects
);
}
@@ -168,8 +175,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
public async find(options: SavedObjectsFindOptions) {
return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.find(options),
- undefined,
- options.namespace
+ undefined
);
}
@@ -179,8 +185,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
) {
return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.bulkGet(objects, options),
- undefined,
- options?.namespace
+ undefined
);
}
@@ -188,7 +193,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return await this.handleEncryptedAttributesInResponse(
await this.options.baseClient.get(type, id, options),
undefined as unknown,
- this.getDescriptorNamespace(type, options?.namespace)
+ getDescriptorNamespace(this.options.baseTypeRegistry, type, options?.namespace)
);
}
@@ -201,7 +206,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
if (!this.options.service.isRegistered(type)) {
return await this.options.baseClient.update(type, id, attributes, options);
}
- const namespace = this.getDescriptorNamespace(type, options?.namespace);
+ const namespace = getDescriptorNamespace(
+ this.options.baseTypeRegistry,
+ type,
+ options?.namespace
+ );
return this.handleEncryptedAttributesInResponse(
await this.options.baseClient.update(
type,
@@ -270,7 +279,6 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
* response portion isn't registered, it is returned as is.
* @param response Raw response returned by the underlying base client.
* @param [objects] Optional list of saved objects with original attributes.
- * @param [namespace] Optional namespace that was used for the saved objects operation.
*/
private async handleEncryptedAttributesInBulkResponse<
T,
@@ -279,12 +287,16 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
| SavedObjectsFindResponse
| SavedObjectsBulkUpdateResponse,
O extends Array> | Array>
- >(response: R, objects?: O, namespace?: string) {
+ >(response: R, objects?: O) {
for (const [index, savedObject] of response.saved_objects.entries()) {
await this.handleEncryptedAttributesInResponse(
savedObject,
objects?.[index].attributes ?? undefined,
- this.getDescriptorNamespace(savedObject.type, namespace)
+ getDescriptorNamespace(
+ this.options.baseTypeRegistry,
+ savedObject.type,
+ savedObject.namespaces ? savedObject.namespaces[0] : undefined
+ )
);
}
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts
new file mode 100644
index 0000000000000..7ba90a5a76ab3
--- /dev/null
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts
@@ -0,0 +1,70 @@
+/*
+ * 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 { savedObjectsTypeRegistryMock } from 'src/core/server/mocks';
+import { getDescriptorNamespace } from './get_descriptor_namespace';
+
+describe('getDescriptorNamespace', () => {
+ describe('namespace agnostic', () => {
+ it('returns undefined', () => {
+ const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create();
+ mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false);
+ mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false);
+ mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(true);
+
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'globaltype', undefined)).toEqual(
+ undefined
+ );
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'globaltype', 'foo-namespace')).toEqual(
+ undefined
+ );
+ });
+ });
+
+ describe('multi-namespace', () => {
+ it('returns undefined', () => {
+ const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create();
+ mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false);
+ mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(true);
+ mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false);
+
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'sharedtype', undefined)).toEqual(
+ undefined
+ );
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'sharedtype', 'foo-namespace')).toEqual(
+ undefined
+ );
+ });
+ });
+
+ describe('single namespace', () => {
+ it('returns `undefined` if provided namespace is undefined or `default`', () => {
+ const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create();
+ mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(true);
+ mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false);
+ mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false);
+
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', undefined)).toEqual(
+ undefined
+ );
+
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', 'default')).toEqual(
+ undefined
+ );
+ });
+
+ it('returns the provided namespace', () => {
+ const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create();
+ mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(true);
+ mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false);
+ mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false);
+
+ expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', 'foo-namespace')).toEqual(
+ 'foo-namespace'
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts
new file mode 100644
index 0000000000000..b2842df909a1d
--- /dev/null
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { ISavedObjectTypeRegistry } from 'kibana/server';
+
+export const getDescriptorNamespace = (
+ typeRegistry: ISavedObjectTypeRegistry,
+ type: string,
+ namespace?: string
+) => {
+ const descriptorNamespace = typeRegistry.isSingleNamespace(type) ? namespace : undefined;
+ return descriptorNamespace === 'default' ? undefined : descriptorNamespace;
+};
diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts
index af00050183b77..0e5be4e4eee5a 100644
--- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts
+++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts
@@ -15,6 +15,7 @@ import {
import { SecurityPluginSetup } from '../../../security/server';
import { EncryptedSavedObjectsService } from '../crypto';
import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper';
+import { getDescriptorNamespace } from './get_descriptor_namespace';
interface SetupSavedObjectsParams {
service: PublicMethodsOf;
@@ -84,7 +85,7 @@ export function setupSavedObjects({
{
type,
id,
- namespace: typeRegistry.isSingleNamespace(type) ? options?.namespace : undefined,
+ namespace: getDescriptorNamespace(typeRegistry, type, options?.namespace),
},
savedObject.attributes as Record
)) as T,
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 4ab00b511b48b..5e38045b88c74 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
@@ -43,17 +43,6 @@ describe('#checkSavedObjectsPrivileges', () => {
describe('when checking multiple namespaces', () => {
const namespaces = [namespace1, namespace2];
- test(`throws an error when Spaces is disabled`, async () => {
- mockSpacesService = undefined;
- const checkSavedObjectsPrivileges = createFactory();
-
- await expect(
- checkSavedObjectsPrivileges(actions, namespaces)
- ).rejects.toThrowErrorMatchingInlineSnapshot(
- `"Can't check saved object privileges for multiple namespaces if Spaces is disabled"`
- );
- });
-
test(`throws an error when using an empty namespaces array`, async () => {
const checkSavedObjectsPrivileges = createFactory();
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 d9b070c72f946..0c2260542bf72 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
@@ -29,21 +29,21 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = (
namespaceOrNamespaces?: string | string[]
) {
const spacesService = getSpacesService();
- if (Array.isArray(namespaceOrNamespaces)) {
- if (spacesService === undefined) {
- throw new Error(
- `Can't check saved object privileges for multiple namespaces if Spaces is disabled`
- );
- } else if (!namespaceOrNamespaces.length) {
+ if (!spacesService) {
+ // Spaces disabled, authorizing globally
+ return await checkPrivilegesWithRequest(request).globally(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 spaceIds = namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x));
return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, actions);
- } else if (spacesService) {
+ } else {
+ // Spaces enabled, authorizing against a single space
const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces);
return await checkPrivilegesWithRequest(request).atSpace(spaceId, actions);
}
- return await checkPrivilegesWithRequest(request).globally(actions);
};
};
};
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 c646cd95228f0..1cf879adc5415 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
@@ -27,6 +27,7 @@ const createSecureSavedObjectsClientWrapperOptions = () => {
const errors = ({
decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError),
decorateGeneralError: jest.fn().mockReturnValue(generalError),
+ createBadRequestError: jest.fn().mockImplementation((message) => new Error(message)),
isNotFoundError: jest.fn().mockReturnValue(false),
} as unknown) as jest.Mocked;
const getSpacesService = jest.fn().mockReturnValue(true);
@@ -73,7 +74,9 @@ const expectForbiddenError = async (fn: Function, args: Record) =>
SavedObjectActions['get']
>).mock.calls;
const actions = clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mock.calls[0][0];
- const spaceId = args.options?.namespace || 'default';
+ const spaceId = args.options?.namespaces
+ ? args.options?.namespaces[0]
+ : args.options?.namespace || 'default';
const ACTION = getCalls[0][1];
const types = getCalls.map((x) => x[0]);
@@ -100,7 +103,7 @@ const expectSuccess = async (fn: Function, args: Record) => {
>).mock.calls;
const ACTION = getCalls[0][1];
const types = getCalls.map((x) => x[0]);
- const spaceIds = [args.options?.namespace || 'default'];
+ const spaceIds = args.options?.namespaces || [args.options?.namespace || 'default'];
expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1);
@@ -128,7 +131,7 @@ const expectPrivilegeCheck = async (fn: Function, args: Record) =>
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1);
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith(
actions,
- args.options?.namespace
+ args.options?.namespace ?? args.options?.namespaces
);
};
@@ -344,7 +347,7 @@ describe('#addToNamespaces', () => {
);
});
- test(`checks privileges for user, actions, and namespace`, async () => {
+ test(`checks privileges for user, actions, and namespaces`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce(
getMockCheckPrivilegesSuccess // create
);
@@ -539,12 +542,12 @@ describe('#find', () => {
});
test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => {
- const options = Object.freeze({ type: type1, namespace: 'some-ns' });
+ const options = Object.freeze({ type: type1, namespaces: ['some-ns'] });
await expectForbiddenError(client.find, { options });
});
test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => {
- const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' });
+ const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] });
await expectForbiddenError(client.find, { options });
});
@@ -552,18 +555,34 @@ describe('#find', () => {
const apiCallReturnValue = { saved_objects: [], foo: 'bar' };
clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any);
- const options = Object.freeze({ type: type1, namespace: 'some-ns' });
+ const options = Object.freeze({ type: type1, namespaces: ['some-ns'] });
const result = await expectSuccess(client.find, { options });
expect(result).toEqual(apiCallReturnValue);
});
- test(`checks privileges for user, actions, and namespace`, async () => {
- const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' });
+ test(`throws BadRequestError when searching across namespaces when spaces is disabled`, async () => {
+ clientOpts = createSecureSavedObjectsClientWrapperOptions();
+ clientOpts.getSpacesService.mockReturnValue(undefined);
+ client = new SecureSavedObjectsClientWrapper(clientOpts);
+
+ // succeed privilege checks by default
+ clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation(
+ getMockCheckPrivilegesSuccess
+ );
+
+ const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] });
+ 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 });
});
test(`filters namespaces that the user doesn't have access to`, async () => {
- const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' });
+ const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] });
await expectObjectsNamespaceFiltering(client.find, { 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 969344afae5e3..621299a0f025e 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
@@ -99,7 +99,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
}
public async find(options: SavedObjectsFindOptions) {
- await this.ensureAuthorized(options.type, 'find', options.namespace, { options });
+ if (
+ this.getSpacesService() == null &&
+ Array.isArray(options.namespaces) &&
+ options.namespaces.length > 0
+ ) {
+ throw this.errors.createBadRequestError(
+ `_find across namespaces is not permitted when the Spaces plugin is disabled.`
+ );
+ }
+ await this.ensureAuthorized(options.type, 'find', options.namespaces, { options });
const response = await this.baseClient.find(options);
return await this.redactSavedObjectsNamespaces(response);
@@ -293,7 +302,11 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
private async redactSavedObjectNamespaces(
savedObject: T
): Promise {
- if (this.getSpacesService() === undefined || savedObject.namespaces == null) {
+ if (
+ this.getSpacesService() === undefined ||
+ savedObject.namespaces == null ||
+ savedObject.namespaces.length === 0
+ ) {
return savedObject;
}
diff --git a/x-pack/plugins/spaces/common/model/types.ts b/x-pack/plugins/spaces/common/model/types.ts
index 58c36da33dbd7..30004c739ee7a 100644
--- a/x-pack/plugins/spaces/common/model/types.ts
+++ b/x-pack/plugins/spaces/common/model/types.ts
@@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace';
+export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace' | 'findSavedObjects';
diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap
index a0fa3a2c75eab..c2df94a0a2936 100644
--- a/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap
+++ b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap
@@ -26,6 +26,8 @@ exports[`#getAll useRbacForRequest is true with purpose='any' throws Boom.forbid
exports[`#getAll useRbacForRequest is true with purpose='copySavedObjectsIntoSpace' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`;
+exports[`#getAll useRbacForRequest is true with purpose='findSavedObjects' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`;
+
exports[`#getAll useRbacForRequest is true with purpose='undefined' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`;
exports[`#update useRbacForRequest is true throws Boom.forbidden when user isn't authorized at space 1`] = `"Unauthorized to update 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 fc2110f15f39d..61b1985c5a0b9 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
@@ -228,15 +228,20 @@ describe('#getAll', () => {
mockAuthorization.actions.login,
},
{
- purpose: 'any',
+ purpose: 'any' as GetSpacePurpose,
expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) =>
mockAuthorization.actions.login,
},
{
- purpose: 'copySavedObjectsIntoSpace',
+ purpose: 'copySavedObjectsIntoSpace' as GetSpacePurpose,
expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) =>
mockAuthorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'),
},
+ {
+ purpose: 'findSavedObjects' as GetSpacePurpose,
+ expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) =>
+ mockAuthorization.actions.savedObject.get('config', 'find'),
+ },
].forEach((scenario) => {
describe(`with purpose='${scenario.purpose}'`, () => {
test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => {
@@ -276,9 +281,7 @@ describe('#getAll', () => {
mockInternalRepository,
request
);
- await expect(
- client.getAll(scenario.purpose as GetSpacePurpose)
- ).rejects.toThrowErrorMatchingSnapshot();
+ await expect(client.getAll(scenario.purpose)).rejects.toThrowErrorMatchingSnapshot();
expect(mockInternalRepository.find).toHaveBeenCalledWith({
type: 'space',
@@ -290,7 +293,7 @@ describe('#getAll', () => {
expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request);
expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith(
savedObjects.map((savedObject) => savedObject.id),
- privilege
+ [privilege]
);
expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(
username,
@@ -336,7 +339,7 @@ describe('#getAll', () => {
mockInternalRepository,
request
);
- const actualSpaces = await client.getAll(scenario.purpose as GetSpacePurpose);
+ const actualSpaces = await client.getAll(scenario.purpose);
expect(actualSpaces).toEqual([expectedSpaces[0]]);
expect(mockInternalRepository.find).toHaveBeenCalledWith({
@@ -349,7 +352,7 @@ describe('#getAll', () => {
expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request);
expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith(
savedObjects.map((savedObject) => savedObject.id),
- privilege
+ [privilege]
);
expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0);
expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(
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 25fc3ad97c0d9..b4b0057a2f5a5 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
@@ -13,15 +13,23 @@ import { SpacesAuditLogger } from '../audit_logger';
import { ConfigType } from '../../config';
import { GetSpacePurpose } from '../../../common/model/types';
-const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = ['any', 'copySavedObjectsIntoSpace'];
+const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = [
+ 'any',
+ 'copySavedObjectsIntoSpace',
+ 'findSavedObjects',
+];
const PURPOSE_PRIVILEGE_MAP: Record<
GetSpacePurpose,
- (authorization: SecurityPluginSetup['authz']) => string
+ (authorization: SecurityPluginSetup['authz']) => string[]
> = {
- any: (authorization) => authorization.actions.login,
- copySavedObjectsIntoSpace: (authorization) =>
+ any: (authorization) => [authorization.actions.login],
+ copySavedObjectsIntoSpace: (authorization) => [
authorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'),
+ ],
+ findSavedObjects: (authorization) => {
+ return [authorization.actions.savedObject.get('config', 'find')];
+ },
};
export class SpacesClient {
@@ -86,7 +94,7 @@ export class SpacesClient {
if (authorized.length === 0) {
this.debugLogger(
- `SpacesClient.getAll(), using RBAC. returning 403/Forbidden. Not authorized for any spaces.`
+ `SpacesClient.getAll(), using RBAC. returning 403/Forbidden. Not authorized for any spaces for ${purpose} purpose.`
);
this.auditLogger.spacesAuthorizationFailure(username, 'getAll');
throw Boom.forbidden();
diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts
index 190429d2dacd4..4d0d75cd4595c 100644
--- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts
+++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts
@@ -9,6 +9,7 @@ import { SpacesSavedObjectsClient } from './spaces_saved_objects_client';
import { spacesServiceMock } from '../spaces_service/spaces_service.mock';
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { SavedObjectTypeRegistry } from 'src/core/server';
+import { SpacesClient } from '../lib/spaces_client';
const typeRegistry = new SavedObjectTypeRegistry();
typeRegistry.registerType({
@@ -48,6 +49,7 @@ const createMockResponse = () => ({
timeFieldName: '@timestamp',
notExpandable: true,
references: [],
+ score: 0,
});
const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
@@ -68,7 +70,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
spacesService,
typeRegistry,
});
- return { client, baseClient };
+ return { client, baseClient, spacesService };
};
describe('#get', () => {
@@ -127,14 +129,6 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
});
describe('#find', () => {
- test(`throws error if options.namespace is specified`, async () => {
- const { client } = await createSpacesSavedObjectsClient();
-
- await expect(client.find({ type: 'foo', namespace: 'bar' })).rejects.toThrow(
- ERROR_NAMESPACE_SPECIFIED
- );
- });
-
test(`passes options.type to baseClient if valid singular type specified`, async () => {
const { client, baseClient } = await createSpacesSavedObjectsClient();
const expectedReturnValue = {
@@ -151,7 +145,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
expect(actualReturnValue).toBe(expectedReturnValue);
expect(baseClient.find).toHaveBeenCalledWith({
type: ['foo'],
- namespace: currentSpace.expectedNamespace,
+ namespaces: [currentSpace.expectedNamespace ?? 'default'],
});
});
@@ -171,8 +165,101 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
expect(actualReturnValue).toBe(expectedReturnValue);
expect(baseClient.find).toHaveBeenCalledWith({
type: ['foo', 'bar'],
- namespace: currentSpace.expectedNamespace,
+ namespaces: [currentSpace.expectedNamespace ?? 'default'],
+ });
+ });
+
+ test(`passes options.namespaces along`, async () => {
+ const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient();
+ const expectedReturnValue = {
+ saved_objects: [createMockResponse()],
+ total: 1,
+ per_page: 0,
+ page: 0,
+ };
+ baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue));
+
+ const spacesClient = (await spacesService.scopedClient(null as any)) as jest.Mocked<
+ SpacesClient
+ >;
+ spacesClient.getAll.mockImplementation(() =>
+ Promise.resolve([
+ { id: 'ns-1', name: '', disabledFeatures: [] },
+ { id: 'ns-2', name: '', disabledFeatures: [] },
+ ])
+ );
+
+ const options = Object.freeze({ type: ['foo', 'bar'], namespaces: ['ns-1', 'ns-2'] });
+ const actualReturnValue = await client.find(options);
+
+ expect(actualReturnValue).toBe(expectedReturnValue);
+ expect(baseClient.find).toHaveBeenCalledWith({
+ type: ['foo', 'bar'],
+ namespaces: ['ns-1', 'ns-2'],
+ });
+ expect(spacesClient.getAll).toHaveBeenCalledWith('findSavedObjects');
+ });
+
+ test(`filters options.namespaces based on authorization`, async () => {
+ const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient();
+ const expectedReturnValue = {
+ saved_objects: [createMockResponse()],
+ total: 1,
+ per_page: 0,
+ page: 0,
+ };
+ baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue));
+
+ const spacesClient = (await spacesService.scopedClient(null as any)) as jest.Mocked<
+ SpacesClient
+ >;
+ spacesClient.getAll.mockImplementation(() =>
+ Promise.resolve([
+ { id: 'ns-1', name: '', disabledFeatures: [] },
+ { id: 'ns-2', name: '', disabledFeatures: [] },
+ ])
+ );
+
+ const options = Object.freeze({ type: ['foo', 'bar'], namespaces: ['ns-1', 'ns-3'] });
+ const actualReturnValue = await client.find(options);
+
+ expect(actualReturnValue).toBe(expectedReturnValue);
+ expect(baseClient.find).toHaveBeenCalledWith({
+ type: ['foo', 'bar'],
+ namespaces: ['ns-1'],
+ });
+ expect(spacesClient.getAll).toHaveBeenCalledWith('findSavedObjects');
+ });
+
+ test(`translates options.namespace: ['*']`, async () => {
+ const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient();
+ const expectedReturnValue = {
+ saved_objects: [createMockResponse()],
+ total: 1,
+ per_page: 0,
+ page: 0,
+ };
+ baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue));
+
+ const spacesClient = (await spacesService.scopedClient(null as any)) as jest.Mocked<
+ SpacesClient
+ >;
+ spacesClient.getAll.mockImplementation(() =>
+ Promise.resolve([
+ { id: 'ns-1', name: '', disabledFeatures: [] },
+ { id: 'ns-2', name: '', disabledFeatures: [] },
+ ])
+ );
+
+ const options = Object.freeze({ type: ['foo', 'bar'], namespaces: ['*'] });
+ const actualReturnValue = await client.find(options);
+
+ expect(actualReturnValue).toBe(expectedReturnValue);
+ expect(baseClient.find).toHaveBeenCalledWith({
+ type: ['foo', 'bar'],
+ namespaces: ['ns-1', 'ns-2'],
});
+ expect(spacesClient.getAll).toHaveBeenCalledWith('findSavedObjects');
});
});
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 6611725be8b67..7e2b302d7cff5 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
@@ -19,6 +19,7 @@ import {
} from 'src/core/server';
import { SpacesServiceSetup } from '../spaces_service/spaces_service';
import { spaceIdToNamespace } from '../lib/utils/namespace';
+import { SpacesClient } from '../lib/spaces_client';
interface SpacesSavedObjectsClientOptions {
baseClient: SavedObjectsClientContract;
@@ -45,12 +46,14 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
private readonly client: SavedObjectsClientContract;
private readonly spaceId: string;
private readonly types: string[];
+ private readonly getSpacesClient: Promise;
public readonly errors: SavedObjectsClientContract['errors'];
constructor(options: SpacesSavedObjectsClientOptions) {
const { baseClient, request, spacesService, typeRegistry } = options;
this.client = baseClient;
+ this.getSpacesClient = spacesService.scopedClient(request);
this.spaceId = spacesService.getSpaceId(request);
this.types = typeRegistry.getAllTypes().map((t) => t.name);
this.errors = baseClient.errors;
@@ -131,19 +134,40 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
* @property {string} [options.sortField]
* @property {string} [options.sortOrder]
* @property {Array} [options.fields]
- * @property {string} [options.namespace]
+ * @property {string} [options.namespaces]
* @property {object} [options.hasReference] - { type, id }
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
public async find(options: SavedObjectsFindOptions) {
throwErrorIfNamespaceSpecified(options);
+ let namespaces = options.namespaces;
+ if (namespaces) {
+ const spacesClient = await this.getSpacesClient;
+ const availableSpaces = await spacesClient.getAll('findSavedObjects');
+ if (namespaces.includes('*')) {
+ namespaces = availableSpaces.map((space) => space.id);
+ } else {
+ namespaces = namespaces.filter((namespace) =>
+ availableSpaces.some((space) => space.id === namespace)
+ );
+ }
+ // This forbidden error allows this scenario to be consistent
+ // with the way the SpacesClient behaves when no spaces are authorized
+ // there.
+ if (namespaces.length === 0) {
+ throw this.errors.decorateForbiddenError(new Error());
+ }
+ } else {
+ namespaces = [this.spaceId];
+ }
+
return await this.client.find({
...options,
type: (options.type ? coerceToArray(options.type) : this.types).filter(
(type) => type !== 'space'
),
- namespace: spaceIdToNamespace(this.spaceId),
+ namespaces,
});
}
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 de036494caa83..5d08421038d3f 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
@@ -92,9 +92,9 @@ const uniq = (arr: T[]): T[] => Array.from(new Set(arr));
const isNamespaceAgnostic = (type: string) => type === 'globaltype';
const isMultiNamespace = (type: string) => type === 'sharedtype';
export const expectResponses = {
- forbidden: (action: string) => (typeOrTypes: string | string[]): ExpectResponseBody => async (
- response: Record
- ) => {
+ forbiddenTypes: (action: string) => (
+ typeOrTypes: string | string[]
+ ): ExpectResponseBody => async (response: Record) => {
const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes];
const uniqueSorted = uniq(types).sort();
expect(response.body).to.eql({
@@ -103,6 +103,13 @@ export const expectResponses = {
message: `Unable to ${action} ${uniqueSorted.join()}`,
});
},
+ forbiddenSpaces: (response: Record) => {
+ expect(response.body).to.eql({
+ statusCode: 403,
+ error: 'Forbidden',
+ message: `Forbidden`,
+ });
+ },
permitted: async (object: Record, testCase: TestCase) => {
const { type, id, failure } = testCase;
if (failure) {
@@ -189,18 +196,36 @@ export const expectResponses = {
*/
export const getTestScenarios = (modifiers?: T[]) => {
const commonUsers = {
- noAccess: { ...NOT_A_KIBANA_USER, description: 'user with no access' },
- superuser: { ...SUPERUSER, description: 'superuser' },
- legacyAll: { ...KIBANA_LEGACY_USER, description: 'legacy user' },
- allGlobally: { ...KIBANA_RBAC_USER, description: 'rbac user with all globally' },
+ noAccess: {
+ ...NOT_A_KIBANA_USER,
+ description: 'user with no access',
+ authorizedAtSpaces: [],
+ },
+ superuser: {
+ ...SUPERUSER,
+ description: 'superuser',
+ authorizedAtSpaces: ['*'],
+ },
+ legacyAll: { ...KIBANA_LEGACY_USER, description: 'legacy user', authorizedAtSpaces: [] },
+ allGlobally: {
+ ...KIBANA_RBAC_USER,
+ description: 'rbac user with all globally',
+ authorizedAtSpaces: ['*'],
+ },
readGlobally: {
...KIBANA_RBAC_DASHBOARD_ONLY_USER,
description: 'rbac user with read globally',
+ authorizedAtSpaces: ['*'],
+ },
+ dualAll: {
+ ...KIBANA_DUAL_PRIVILEGES_USER,
+ description: 'dual-privileges user',
+ authorizedAtSpaces: ['*'],
},
- dualAll: { ...KIBANA_DUAL_PRIVILEGES_USER, description: 'dual-privileges user' },
dualRead: {
...KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER,
description: 'dual-privileges readonly user',
+ authorizedAtSpaces: ['*'],
},
};
@@ -236,18 +261,22 @@ export const getTestScenarios = (modifiers?: T[]) => {
allAtDefaultSpace: {
...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER,
description: 'rbac user with all at default space',
+ authorizedAtSpaces: ['default'],
},
readAtDefaultSpace: {
...KIBANA_RBAC_DEFAULT_SPACE_READ_USER,
description: 'rbac user with read at default space',
+ authorizedAtSpaces: ['default'],
},
allAtSpace1: {
...KIBANA_RBAC_SPACE_1_ALL_USER,
description: 'rbac user with all at space_1',
+ authorizedAtSpaces: ['space_1'],
},
readAtSpace1: {
...KIBANA_RBAC_SPACE_1_READ_USER,
description: 'rbac user with read at space_1',
+ authorizedAtSpaces: ['space_1'],
},
},
},
@@ -260,14 +289,17 @@ export const getTestScenarios = (modifiers?: T[]) => {
allAtSpace: {
...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER,
description: 'user with all at the space',
+ authorizedAtSpaces: ['default'],
},
readAtSpace: {
...KIBANA_RBAC_DEFAULT_SPACE_READ_USER,
description: 'user with read at the space',
+ authorizedAtSpaces: ['default'],
},
allAtOtherSpace: {
...KIBANA_RBAC_SPACE_1_ALL_USER,
description: 'user with all at other space',
+ authorizedAtSpaces: ['space_1'],
},
},
},
@@ -275,14 +307,20 @@ export const getTestScenarios = (modifiers?: T[]) => {
spaceId: SPACE_1_ID,
users: {
...commonUsers,
- allAtSpace: { ...KIBANA_RBAC_SPACE_1_ALL_USER, description: 'user with all at the space' },
+ allAtSpace: {
+ ...KIBANA_RBAC_SPACE_1_ALL_USER,
+ description: 'user with all at the space',
+ authorizedAtSpaces: ['space_1'],
+ },
readAtSpace: {
...KIBANA_RBAC_SPACE_1_READ_USER,
description: 'user with read at the space',
+ authorizedAtSpaces: ['space_1'],
},
allAtOtherSpace: {
...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER,
description: 'user with all at other space',
+ authorizedAtSpaces: ['default'],
},
},
},
diff --git a/x-pack/test/saved_object_api_integration/common/lib/types.ts b/x-pack/test/saved_object_api_integration/common/lib/types.ts
index f6e6d391ae905..56e6a992b6b62 100644
--- a/x-pack/test/saved_object_api_integration/common/lib/types.ts
+++ b/x-pack/test/saved_object_api_integration/common/lib/types.ts
@@ -28,4 +28,5 @@ export interface TestUser {
username: string;
password: string;
description: string;
+ authorizedAtSpaces: string[];
}
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 dd32c42597c32..bc356927cc0af 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
@@ -39,7 +39,7 @@ export const TEST_CASES = Object.freeze({
});
export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) {
- const expectForbidden = expectResponses.forbidden('bulk_create');
+ const expectForbidden = expectResponses.forbiddenTypes('bulk_create');
const expectResponseBody = (
testCases: BulkCreateTestCase | BulkCreateTestCase[],
statusCode: 200 | 403,
diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts
index f5ec5b6560fc9..8de54fe499c07 100644
--- a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts
+++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts
@@ -28,7 +28,7 @@ const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest) {
- const expectForbidden = expectResponses.forbidden('bulk_get');
+ const expectForbidden = expectResponses.forbiddenTypes('bulk_get');
const expectResponseBody = (
testCases: BulkGetTestCase | BulkGetTestCase[],
statusCode: 200 | 403
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 0073b79a934a5..0b5656004492a 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
@@ -31,7 +31,7 @@ const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest) {
- const expectForbidden = expectResponses.forbidden('bulk_update');
+ const expectForbidden = expectResponses.forbiddenTypes('bulk_update');
const expectResponseBody = (
testCases: BulkUpdateTestCase | BulkUpdateTestCase[],
statusCode: 200 | 403
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 8a3e4250040cd..2a5ab696c4f53 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
@@ -41,7 +41,7 @@ export const TEST_CASES = Object.freeze({
});
export function createTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) {
- const expectForbidden = expectResponses.forbidden('create');
+ const expectForbidden = expectResponses.forbiddenTypes('create');
const expectResponseBody = (
testCase: CreateTestCase,
spaceId = SPACES.DEFAULT.spaceId
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 c02b6e9e5cc4b..3179b1b0c9ac5 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
@@ -28,7 +28,7 @@ const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest) {
- const expectForbidden = expectResponses.forbidden('delete');
+ const expectForbidden = expectResponses.forbiddenTypes('delete');
const expectResponseBody = (testCase: DeleteTestCase): ExpectResponseBody => async (
response: Record
) => {
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 394693677699f..ff22cdaeafd06 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
@@ -93,8 +93,8 @@ const getTestTitle = ({ failure, title }: ExportTestCase) => {
};
export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest) {
- const expectForbiddenBulkGet = expectResponses.forbidden('bulk_get');
- const expectForbiddenFind = expectResponses.forbidden('find');
+ const expectForbiddenBulkGet = expectResponses.forbiddenTypes('bulk_get');
+ const expectForbiddenFind = expectResponses.forbiddenTypes('find');
const expectResponseBody = (testCase: ExportTestCase): ExpectResponseBody => async (
response: Record
) => {
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 13f411fc14fc8..882451c28bfe4 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
@@ -7,154 +7,260 @@
import expect from '@kbn/expect';
import { SuperTest } from 'supertest';
import querystring from 'querystring';
+import { Assign } from '@kbn/utility-types';
import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases';
import { SPACES } from '../lib/spaces';
import { expectResponses, getUrlPrefix } from '../lib/saved_object_test_utils';
-import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types';
+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 FindTestDefinition extends TestDefinition {
request: { query: string };
}
export type FindTestSuite = TestSuite;
+
+type FindSavedObjectCase = Assign;
+
export interface FindTestCase {
title: string;
query: string;
successResult?: {
- savedObjects?: TestCase | TestCase[];
+ savedObjects?: FindSavedObjectCase | FindSavedObjectCase[];
page?: number;
perPage?: number;
total?: number;
};
- failure?: 400 | 403;
+ failure?: {
+ statusCode: 400 | 403;
+ reason:
+ | 'forbidden_types'
+ | 'forbidden_namespaces'
+ | 'cross_namespace_not_permitted'
+ | 'bad_request';
+ };
}
-export const getTestCases = (spaceId?: string) => ({
- singleNamespaceType: {
- title: 'find single-namespace type',
- query: 'type=isolatedtype&fields=title',
- successResult: {
- savedObjects:
- spaceId === SPACE_1_ID
- ? CASES.SINGLE_NAMESPACE_SPACE_1
- : spaceId === SPACE_2_ID
- ? CASES.SINGLE_NAMESPACE_SPACE_2
- : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE,
- },
- } as FindTestCase,
- multiNamespaceType: {
- title: 'find multi-namespace type',
- query: 'type=sharedtype&fields=title',
- successResult: {
- savedObjects:
- 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,
- },
- } as FindTestCase,
- namespaceAgnosticType: {
- title: 'find namespace-agnostic type',
- query: 'type=globaltype&fields=title',
- successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC },
- } as FindTestCase,
- hiddenType: { title: 'find hidden type', query: 'type=hiddentype&fields=name' } as FindTestCase,
- unknownType: { title: 'find unknown type', query: 'type=wigwags' } as FindTestCase,
- pageBeyondTotal: {
- title: 'find page beyond total',
- query: 'type=isolatedtype&page=100&per_page=100',
- successResult: { page: 100, perPage: 100, total: 1, savedObjects: [] },
- } as FindTestCase,
- unknownSearchField: {
- title: 'find unknown search field',
- query: 'type=url&search_fields=a',
- } as FindTestCase,
- filterWithNamespaceAgnosticType: {
- title: 'filter with namespace-agnostic type',
- query: 'type=globaltype&filter=globaltype.attributes.title:*global*',
- successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC },
- } as FindTestCase,
- filterWithHiddenType: {
- title: 'filter with hidden type',
- query: `type=hiddentype&fields=name&filter=hiddentype.attributes.title:'hello'`,
- } as FindTestCase,
- filterWithUnknownType: {
- title: 'filter with unknown type',
- query: `type=wigwags&filter=wigwags.attributes.title:'unknown'`,
- } as FindTestCase,
- filterWithDisallowedType: {
- title: 'filter with disallowed type',
- query: `type=globaltype&filter=dashboard.title:'Requests'`,
- failure: 400,
- } as FindTestCase,
-});
+const TEST_CASES = [
+ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespaces: ['default'] },
+ { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespaces: ['space_1'] },
+ { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespaces: ['space_2'] },
+ { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespaces: ['default', 'space_1'] },
+ { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespaces: ['space_1'] },
+ { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespaces: ['space_2'] },
+ { ...CASES.NAMESPACE_AGNOSTIC, namespaces: undefined },
+ { ...CASES.HIDDEN, namespaces: undefined },
+];
+
+expect(TEST_CASES.length).to.eql(
+ Object.values(CASES).length,
+ 'Unhandled test cases in `find` suite'
+);
+
+export const getTestCases = (
+ { currentSpace, crossSpaceSearch }: { currentSpace?: string; crossSpaceSearch?: string[] } = {
+ currentSpace: undefined,
+ crossSpaceSearch: undefined,
+ }
+) => {
+ const crossSpaceIds = crossSpaceSearch?.filter((s) => s !== (currentSpace ?? 'default')) ?? [];
+ const isCrossSpaceSearch = crossSpaceIds.length > 0;
+ const isWildcardSearch = crossSpaceIds.includes('*');
+
+ const namespacesQueryParam = isCrossSpaceSearch
+ ? `&namespaces=${crossSpaceIds.join('&namespaces=')}`
+ : '';
+
+ const buildTitle = (title: string) =>
+ crossSpaceSearch ? `${title} (cross-space ${isWildcardSearch ? 'with wildcard' : ''})` : title;
+
+ type CasePredicate = (testCase: TestCase) => boolean;
+ const getExpectedSavedObjects = (predicate: CasePredicate) => {
+ if (isCrossSpaceSearch) {
+ // all other cross-space tests are written to test that we exclude the current space.
+ // the wildcard scenario verifies current space functionality
+ if (isWildcardSearch) {
+ return TEST_CASES.filter(predicate);
+ }
+
+ return TEST_CASES.filter((t) => {
+ const hasOtherNamespaces =
+ Array.isArray(t.namespaces) &&
+ t.namespaces!.some((ns) => ns !== (currentSpace ?? 'default'));
+ return hasOtherNamespaces && predicate(t);
+ });
+ }
+ return TEST_CASES.filter(
+ (t) => (!t.namespaces || t.namespaces.includes(currentSpace ?? 'default')) && predicate(t)
+ );
+ };
+
+ return {
+ singleNamespaceType: {
+ title: buildTitle('find single-namespace type'),
+ query: `type=isolatedtype&fields=title${namespacesQueryParam}`,
+ successResult: {
+ savedObjects: getExpectedSavedObjects((t) => t.type === 'isolatedtype'),
+ },
+ } as FindTestCase,
+ multiNamespaceType: {
+ title: buildTitle('find multi-namespace type'),
+ query: `type=sharedtype&fields=title${namespacesQueryParam}`,
+ successResult: {
+ // expected depends on which spaces the user is authorized against...
+ savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype'),
+ },
+ } as FindTestCase,
+ namespaceAgnosticType: {
+ title: buildTitle('find namespace-agnostic type'),
+ query: `type=globaltype&fields=title${namespacesQueryParam}`,
+ successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC },
+ } as FindTestCase,
+ hiddenType: {
+ title: buildTitle('find hidden type'),
+ query: `type=hiddentype&fields=name${namespacesQueryParam}`,
+ } as FindTestCase,
+ unknownType: {
+ title: buildTitle('find unknown type'),
+ query: `type=wigwags${namespacesQueryParam}`,
+ } as FindTestCase,
+ pageBeyondTotal: {
+ title: buildTitle('find page beyond total'),
+ query: `type=isolatedtype&page=100&per_page=100${namespacesQueryParam}`,
+ successResult: {
+ page: 100,
+ perPage: 100,
+ total: -1,
+ savedObjects: [],
+ },
+ } as FindTestCase,
+ unknownSearchField: {
+ title: buildTitle('find unknown search field'),
+ query: `type=url&search_fields=a${namespacesQueryParam}`,
+ } as FindTestCase,
+ filterWithNamespaceAgnosticType: {
+ title: buildTitle('filter with namespace-agnostic type'),
+ query: `type=globaltype&filter=globaltype.attributes.title:*global*${namespacesQueryParam}`,
+ successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC },
+ } as FindTestCase,
+ filterWithHiddenType: {
+ title: buildTitle('filter with hidden type'),
+ query: `type=hiddentype&fields=name&filter=hiddentype.attributes.title:'hello'${namespacesQueryParam}`,
+ } as FindTestCase,
+ filterWithUnknownType: {
+ title: buildTitle('filter with unknown type'),
+ query: `type=wigwags&filter=wigwags.attributes.title:'unknown'${namespacesQueryParam}`,
+ } as FindTestCase,
+ filterWithDisallowedType: {
+ title: buildTitle('filter with disallowed type'),
+ query: `type=globaltype&filter=dashboard.title:'Requests'${namespacesQueryParam}`,
+ failure: {
+ statusCode: 400,
+ reason: 'bad_request',
+ },
+ } as FindTestCase,
+ };
+};
+
export const createRequest = ({ query }: FindTestCase) => ({ query });
const getTestTitle = ({ failure, title }: FindTestCase) => {
let description = 'success';
- if (failure === 400) {
+ if (failure?.statusCode === 400) {
description = 'bad request';
- } else if (failure === 403) {
+ } else if (failure?.statusCode === 403) {
description = 'forbidden';
}
return `${description} ["${title}"]`;
};
export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) {
- const expectForbidden = expectResponses.forbidden('find');
- const expectResponseBody = (testCase: FindTestCase): ExpectResponseBody => async (
- response: Record
- ) => {
+ const expectForbiddenTypes = expectResponses.forbiddenTypes('find');
+ const expectForbiddeNamespaces = expectResponses.forbiddenSpaces;
+ const expectResponseBody = (
+ testCase: FindTestCase,
+ user?: TestUser
+ ): ExpectResponseBody => async (response: Record) => {
const { failure, successResult = {}, query } = testCase;
const parsedQuery = querystring.parse(query);
- if (failure === 403) {
- const type = parsedQuery.type;
- await expectForbidden(type)(response);
- } else if (failure === 400) {
- const type = (parsedQuery.filter as string).split('.')[0];
- expect(response.body.error).to.eql('Bad Request');
- expect(response.body.statusCode).to.eql(failure);
- expect(response.body.message).to.eql(`This type ${type} is not allowed: Bad Request`);
+ if (failure?.statusCode === 403) {
+ if (failure?.reason === 'forbidden_types') {
+ const type = parsedQuery.type;
+ await expectForbiddenTypes(type)(response);
+ } else if (failure?.reason === 'forbidden_namespaces') {
+ await expectForbiddeNamespaces(response);
+ } else {
+ throw new Error(`Unexpected failure reason: ${failure?.reason}`);
+ }
+ } else if (failure?.statusCode === 400) {
+ if (failure?.reason === 'bad_request') {
+ const type = (parsedQuery.filter as string).split('.')[0];
+ expect(response.body.error).to.eql('Bad Request');
+ expect(response.body.statusCode).to.eql(failure?.statusCode);
+ expect(response.body.message).to.eql(`This type ${type} is not allowed: Bad Request`);
+ } else if (failure?.reason === 'cross_namespace_not_permitted') {
+ expect(response.body.error).to.eql('Bad Request');
+ expect(response.body.statusCode).to.eql(failure?.statusCode);
+ expect(response.body.message).to.eql(
+ `_find across namespaces is not permitted when the Spaces plugin is disabled.: Bad Request`
+ );
+ } else {
+ throw new Error(`Unexpected failure reason: ${failure?.reason}`);
+ }
} else {
// 2xx
expect(response.body).not.to.have.property('error');
const { page = 1, perPage = 20, total, savedObjects = [] } = successResult;
const savedObjectsArray = Array.isArray(savedObjects) ? savedObjects : [savedObjects];
+ const authorizedSavedObjects = savedObjectsArray.filter(
+ (so) =>
+ !user ||
+ !so.namespaces ||
+ so.namespaces.some(
+ (ns) => user.authorizedAtSpaces.includes(ns) || user.authorizedAtSpaces.includes('*')
+ )
+ );
expect(response.body.page).to.eql(page);
expect(response.body.per_page).to.eql(perPage);
- expect(response.body.total).to.eql(total || savedObjectsArray.length);
- for (let i = 0; i < savedObjectsArray.length; i++) {
+
+ // Negative totals are skipped for test simplifications
+ if (!total || total >= 0) {
+ expect(response.body.total).to.eql(total || authorizedSavedObjects.length);
+ }
+
+ authorizedSavedObjects.sort((s1, s2) => (s1.id < s2.id ? -1 : 1));
+ response.body.saved_objects.sort((s1: any, s2: any) => (s1.id < s2.id ? -1 : 1));
+
+ for (let i = 0; i < authorizedSavedObjects.length; i++) {
const object = response.body.saved_objects[i];
- const { type: expectedType, id: expectedId } = savedObjectsArray[i];
+ const { type: expectedType, id: expectedId } = authorizedSavedObjects[i];
expect(object.type).to.eql(expectedType);
expect(object.id).to.eql(expectedId);
expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/);
+ expect(object.namespaces).to.eql(object.namespaces);
// don't test attributes, version, or references
}
}
};
const createTestDefinitions = (
testCases: FindTestCase | FindTestCase[],
- forbidden: boolean,
+ failure: FindTestCase['failure'] | false,
options?: {
+ user?: TestUser;
responseBodyOverride?: ExpectResponseBody;
}
): FindTestDefinition[] => {
let cases = Array.isArray(testCases) ? testCases : [testCases];
- if (forbidden) {
+ if (failure) {
// override the expected result in each test case
- cases = cases.map((x) => ({ ...x, failure: 403 }));
+ cases = cases.map((x) => ({ ...x, failure }));
}
return cases.map((x) => ({
title: getTestTitle(x),
- responseStatusCode: x.failure ?? 200,
+ responseStatusCode: x.failure?.statusCode ?? 200,
request: createRequest(x),
- responseBody: options?.responseBodyOverride || expectResponseBody(x),
+ responseBody: options?.responseBodyOverride || expectResponseBody(x, options?.user),
}));
};
@@ -171,6 +277,7 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest)
for (const test of tests) {
it(`should return ${test.responseStatusCode} ${test.title}`, async () => {
const query = test.request.query ? `?${test.request.query}` : '';
+
await supertest
.get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find${query}`)
.auth(user?.username, user?.password)
diff --git a/x-pack/test/saved_object_api_integration/common/suites/get.ts b/x-pack/test/saved_object_api_integration/common/suites/get.ts
index cb29c1fb1ff37..fb03cd548d41a 100644
--- a/x-pack/test/saved_object_api_integration/common/suites/get.ts
+++ b/x-pack/test/saved_object_api_integration/common/suites/get.ts
@@ -24,7 +24,7 @@ const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest) {
- const expectForbidden = expectResponses.forbidden('get');
+ const expectForbidden = expectResponses.forbiddenTypes('get');
const expectResponseBody = (testCase: GetTestCase): ExpectResponseBody => async (
response: Record
) => {
diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts
index a5d2ca238d34e..ed57c6eb16b9a 100644
--- a/x-pack/test/saved_object_api_integration/common/suites/import.ts
+++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts
@@ -38,7 +38,7 @@ export const TEST_CASES = Object.freeze({
});
export function importTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) {
- const expectForbidden = expectResponses.forbidden('bulk_create');
+ const expectForbidden = expectResponses.forbiddenTypes('bulk_create');
const expectResponseBody = (
testCases: ImportTestCase | ImportTestCase[],
statusCode: 200 | 403,
diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts
index cb48f26ed645c..822214cd6dc6a 100644
--- a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts
+++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts
@@ -43,7 +43,7 @@ export function resolveImportErrorsTestSuiteFactory(
esArchiver: any,
supertest: SuperTest
) {
- const expectForbidden = expectResponses.forbidden('bulk_create');
+ const expectForbidden = expectResponses.forbiddenTypes('bulk_create');
const expectResponseBody = (
testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[],
statusCode: 200 | 403,
diff --git a/x-pack/test/saved_object_api_integration/common/suites/update.ts b/x-pack/test/saved_object_api_integration/common/suites/update.ts
index e480dab151ba9..82f4699babf46 100644
--- a/x-pack/test/saved_object_api_integration/common/suites/update.ts
+++ b/x-pack/test/saved_object_api_integration/common/suites/update.ts
@@ -31,7 +31,7 @@ const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest) {
- const expectForbidden = expectResponses.forbidden('update');
+ const expectForbidden = expectResponses.forbiddenTypes('update');
const expectResponseBody = (testCase: UpdateTestCase): ExpectResponseBody => async (
response: Record
) => {
diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts
index ada997020ca78..6ac77507df473 100644
--- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts
+++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts
@@ -7,10 +7,11 @@
import { getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { TestUser } from '../../common/lib/types';
import { FtrProviderContext } from '../../common/ftr_provider_context';
-import { findTestSuiteFactory, getTestCases, FindTestDefinition } from '../../common/suites/find';
+import { findTestSuiteFactory, getTestCases } from '../../common/suites/find';
+
+const createTestCases = (currentSpace: string, crossSpaceSearch: string[]) => {
+ const cases = getTestCases({ currentSpace, crossSpaceSearch });
-const createTestCases = (spaceId: string) => {
- const cases = getTestCases(spaceId);
const normalTypes = [
cases.singleNamespaceType,
cases.multiNamespaceType,
@@ -35,40 +36,107 @@ export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest);
- const createTests = (spaceId: string) => {
- const { normalTypes, hiddenAndUnknownTypes, allTypes } = createTestCases(spaceId);
+ const createTests = (spaceId: string, user: TestUser) => {
+ const currentSpaceCases = createTestCases(spaceId, []);
+
+ const explicitCrossSpace = createTestCases(spaceId, ['default', 'space_1', 'space_2']);
+ const wildcardCrossSpace = createTestCases(spaceId, ['*']);
+
+ if (user.username === 'elastic') {
+ return {
+ currentSpace: createTestDefinitions(currentSpaceCases.allTypes, false, { user }),
+ crossSpace: createTestDefinitions(explicitCrossSpace.allTypes, false, { user }),
+ };
+ }
+
+ const authorizedAtCurrentSpace =
+ user.authorizedAtSpaces.includes(spaceId) || user.authorizedAtSpaces.includes('*');
+
+ const authorizedExplicitCrossSpaces = ['default', 'space_1', 'space_2'].filter(
+ (s) =>
+ user.authorizedAtSpaces.includes('*') ||
+ (s !== spaceId && user.authorizedAtSpaces.includes(s))
+ );
+
+ const authorizedWildcardCrossSpaces = ['default', 'space_1', 'space_2'].filter(
+ (s) => user.authorizedAtSpaces.includes('*') || user.authorizedAtSpaces.includes(s)
+ );
+
+ const explicitCrossSpaceDefinitions =
+ authorizedExplicitCrossSpaces.length > 0
+ ? [
+ createTestDefinitions(explicitCrossSpace.normalTypes, false, { user }),
+ createTestDefinitions(
+ explicitCrossSpace.hiddenAndUnknownTypes,
+ {
+ statusCode: 403,
+ reason: 'forbidden_types',
+ },
+ { user }
+ ),
+ ].flat()
+ : createTestDefinitions(
+ explicitCrossSpace.allTypes,
+ {
+ statusCode: 403,
+ reason: 'forbidden_namespaces',
+ },
+ { user }
+ );
+
+ const wildcardCrossSpaceDefinitions =
+ authorizedWildcardCrossSpaces.length > 0
+ ? [
+ createTestDefinitions(wildcardCrossSpace.normalTypes, false, { user }),
+ createTestDefinitions(
+ wildcardCrossSpace.hiddenAndUnknownTypes,
+ {
+ statusCode: 403,
+ reason: 'forbidden_types',
+ },
+ { user }
+ ),
+ ].flat()
+ : createTestDefinitions(
+ wildcardCrossSpace.allTypes,
+ {
+ statusCode: 403,
+ reason: 'forbidden_namespaces',
+ },
+ { user }
+ );
+
return {
- unauthorized: createTestDefinitions(allTypes, true),
- authorized: [
- createTestDefinitions(normalTypes, false),
- createTestDefinitions(hiddenAndUnknownTypes, true),
- ].flat(),
- superuser: createTestDefinitions(allTypes, false),
+ currentSpace: authorizedAtCurrentSpace
+ ? [
+ createTestDefinitions(currentSpaceCases.normalTypes, false, {
+ user,
+ }),
+ createTestDefinitions(currentSpaceCases.hiddenAndUnknownTypes, {
+ statusCode: 403,
+ reason: 'forbidden_types',
+ }),
+ ].flat()
+ : createTestDefinitions(currentSpaceCases.allTypes, {
+ statusCode: 403,
+ reason: 'forbidden_types',
+ }),
+ crossSpace: [...explicitCrossSpaceDefinitions, ...wildcardCrossSpaceDefinitions],
};
};
describe('_find', () => {
getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => {
const suffix = ` within the ${spaceId} space`;
- const { unauthorized, authorized, superuser } = createTests(spaceId);
- const _addTests = (user: TestUser, tests: FindTestDefinition[]) => {
- addTests(`${user.description}${suffix}`, { user, spaceId, tests });
- };
- [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach((user) => {
- _addTests(user, unauthorized);
- });
- [
- users.dualAll,
- users.dualRead,
- users.allGlobally,
- users.readGlobally,
- users.allAtSpace,
- users.readAtSpace,
- ].forEach((user) => {
- _addTests(user, authorized);
+ Object.values(users).forEach((user) => {
+ const { currentSpace, crossSpace } = createTests(spaceId, user);
+ addTests(`${user.description}${suffix}`, {
+ user,
+ spaceId,
+ tests: [...currentSpace, ...crossSpace],
+ });
});
- _addTests(users.superuser, superuser);
});
});
}
diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts
index 4ffdb4d477b8b..3a435119436ca 100644
--- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts
+++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts
@@ -7,10 +7,11 @@
import { getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { TestUser } from '../../common/lib/types';
import { FtrProviderContext } from '../../common/ftr_provider_context';
-import { findTestSuiteFactory, getTestCases, FindTestDefinition } from '../../common/suites/find';
+import { findTestSuiteFactory, getTestCases } from '../../common/suites/find';
+
+const createTestCases = (crossSpaceSearch: string[]) => {
+ const cases = getTestCases({ crossSpaceSearch });
-const createTestCases = () => {
- const cases = getTestCases();
const normalTypes = [
cases.singleNamespaceType,
cases.multiNamespaceType,
@@ -35,39 +36,58 @@ export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest);
- const createTests = () => {
- const { normalTypes, hiddenAndUnknownTypes, allTypes } = createTestCases();
+ const createTests = (user: TestUser) => {
+ const defaultCases = createTestCases([]);
+ const crossSpaceCases = createTestCases(['default', 'space_1', 'space_2']);
+
+ if (user.username === 'elastic') {
+ return {
+ defaultCases: createTestDefinitions(defaultCases.allTypes, false, { user }),
+ crossSpace: createTestDefinitions(
+ crossSpaceCases.allTypes,
+ {
+ statusCode: 400,
+ reason: 'cross_namespace_not_permitted',
+ },
+ { user }
+ ),
+ };
+ }
+
+ const authorizedGlobally = user.authorizedAtSpaces.includes('*');
+
return {
- unauthorized: createTestDefinitions(allTypes, true),
- authorized: [
- createTestDefinitions(normalTypes, false),
- createTestDefinitions(hiddenAndUnknownTypes, true),
- ].flat(),
- superuser: createTestDefinitions(allTypes, false),
+ defaultCases: authorizedGlobally
+ ? [
+ createTestDefinitions(defaultCases.normalTypes, false, {
+ user,
+ }),
+ createTestDefinitions(defaultCases.hiddenAndUnknownTypes, {
+ statusCode: 403,
+ reason: 'forbidden_types',
+ }),
+ ].flat()
+ : createTestDefinitions(defaultCases.allTypes, {
+ statusCode: 403,
+ reason: 'forbidden_types',
+ }),
+ crossSpace: createTestDefinitions(
+ crossSpaceCases.allTypes,
+ {
+ statusCode: 400,
+ reason: 'cross_namespace_not_permitted',
+ },
+ { user }
+ ),
};
};
describe('_find', () => {
getTestScenarios().security.forEach(({ users }) => {
- const { unauthorized, authorized, superuser } = createTests();
- const _addTests = (user: TestUser, tests: FindTestDefinition[]) => {
- addTests(user.description, { user, tests });
- };
-
- [
- users.noAccess,
- users.legacyAll,
- users.allAtDefaultSpace,
- users.readAtDefaultSpace,
- users.allAtSpace1,
- users.readAtSpace1,
- ].forEach((user) => {
- _addTests(user, unauthorized);
- });
- [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach((user) => {
- _addTests(user, authorized);
+ Object.values(users).forEach((user) => {
+ const { defaultCases, crossSpace } = createTests(user);
+ addTests(`${user.description}`, { user, tests: [...defaultCases, ...crossSpace] });
});
- _addTests(users.superuser, superuser);
});
});
}
diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts
index 2fe707df5ce88..1d46985916cd5 100644
--- a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts
+++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts
@@ -8,8 +8,8 @@ import { getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { findTestSuiteFactory, getTestCases } from '../../common/suites/find';
-const createTestCases = (spaceId: string) => {
- const cases = getTestCases(spaceId);
+const createTestCases = (spaceId: string, crossSpaceSearch: string[]) => {
+ const cases = getTestCases({ currentSpace: spaceId, crossSpaceSearch });
return Object.values(cases);
};
@@ -18,15 +18,20 @@ export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest);
- const createTests = (spaceId: string) => {
- const testCases = createTestCases(spaceId);
+ const createTests = (spaceId: string, crossSpaceSearch: string[]) => {
+ const testCases = createTestCases(spaceId, crossSpaceSearch);
return createTestDefinitions(testCases, false);
};
describe('_find', () => {
getTestScenarios().spaces.forEach(({ spaceId }) => {
- const tests = createTests(spaceId);
- addTests(`within the ${spaceId} space`, { spaceId, tests });
+ const currentSpaceTests = createTests(spaceId, []);
+ const explicitCrossSpaceTests = createTests(spaceId, ['default', 'space_1', 'space_2']);
+ const wildcardCrossSpaceTests = createTests(spaceId, ['*']);
+ addTests(`within the ${spaceId} space`, {
+ spaceId,
+ tests: [...currentSpaceTests, ...explicitCrossSpaceTests, ...wildcardCrossSpaceTests],
+ });
});
});
}
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 35ef8a81c6cfc..219190cb28002 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
@@ -45,7 +45,7 @@ export function shareAddTestSuiteFactory(esArchiver: any, supertest: SuperTest ({
});
export function shareRemoveTestSuiteFactory(esArchiver: any, supertest: SuperTest) {
- const expectForbidden = expectResponses.forbidden('delete');
+ const expectForbidden = expectResponses.forbiddenTypes('delete');
const expectResponseBody = (testCase: ShareRemoveTestCase): ExpectResponseBody => async (
response: Record
) => {