From d666038c8f6767f6790cb78f29e0027ff73d83ee Mon Sep 17 00:00:00 2001
From: Joe Portner <5295965+jportner@users.noreply.github.com>
Date: Tue, 22 Sep 2020 12:40:38 -0400
Subject: [PATCH 1/3] Change saved objects client `find` to allow partial
authorization (#77699)
---
...gin-core-public.savedobjectsfindoptions.md | 1 +
...dobjectsfindoptions.typetonamespacesmap.md | 13 ++
...gin-core-server.savedobjectsfindoptions.md | 1 +
...dobjectsfindoptions.typetonamespacesmap.md | 13 ++
...core-server.savedobjectsrepository.find.md | 4 +-
...ugin-core-server.savedobjectsrepository.md | 2 +-
...vedobjectsutils.createemptyfindresponse.md | 13 ++
...na-plugin-core-server.savedobjectsutils.md | 1 +
src/core/public/public.api.md | 1 +
.../saved_objects/saved_objects_client.ts | 2 +-
.../service/lib/repository.test.js | 93 +++++++--
.../saved_objects/service/lib/repository.ts | 65 ++++---
.../lib/search_dsl/query_params.test.ts | 99 ++++++----
.../service/lib/search_dsl/query_params.ts | 25 ++-
.../service/lib/search_dsl/search_dsl.test.ts | 4 +-
.../service/lib/search_dsl/search_dsl.ts | 3 +
.../saved_objects/service/lib/utils.test.ts | 25 ++-
.../server/saved_objects/service/lib/utils.ts | 18 ++
src/core/server/saved_objects/types.ts | 8 +
src/core/server/server.api.md | 4 +-
...ecure_saved_objects_client_wrapper.test.ts | 75 ++++++-
.../secure_saved_objects_client_wrapper.ts | 184 +++++++++++++-----
.../server/lib/spaces_client/spaces_client.ts | 2 +-
.../spaces_saved_objects_client.test.ts | 30 +++
.../spaces_saved_objects_client.ts | 37 ++--
.../common/lib/saved_object_test_cases.ts | 54 ++++-
.../common/lib/saved_object_test_utils.ts | 45 ++---
.../common/lib/types.ts | 1 +
.../common/suites/bulk_create.ts | 16 +-
.../common/suites/bulk_get.ts | 5 +-
.../common/suites/bulk_update.ts | 5 +-
.../common/suites/create.ts | 13 +-
.../common/suites/delete.ts | 5 +-
.../common/suites/export.ts | 61 +++---
.../common/suites/find.ts | 166 ++++++----------
.../common/suites/get.ts | 2 +-
.../common/suites/import.ts | 2 +-
.../common/suites/resolve_import_errors.ts | 2 +-
.../common/suites/update.ts | 5 +-
.../security_and_spaces/apis/bulk_create.ts | 36 +++-
.../security_and_spaces/apis/create.ts | 32 ++-
.../security_and_spaces/apis/export.ts | 32 ++-
.../security_and_spaces/apis/find.ts | 119 ++++++-----
.../security_only/apis/bulk_create.ts | 28 ++-
.../security_only/apis/create.ts | 27 ++-
.../security_only/apis/export.ts | 32 ++-
.../security_only/apis/find.ts | 44 ++---
.../spaces_only/apis/bulk_create.ts | 68 ++++---
.../spaces_only/apis/create.ts | 50 +++--
.../spaces_only/apis/find.ts | 19 +-
50 files changed, 1067 insertions(+), 525 deletions(-)
create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md
create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md
create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md
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 903462ac3039d..470a41f30afbf 100644
--- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md
+++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md
@@ -29,4 +29,5 @@ export interface SavedObjectsFindOptions
| [sortField](./kibana-plugin-core-public.savedobjectsfindoptions.sortfield.md) | string
| |
| [sortOrder](./kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md) | string
| |
| [type](./kibana-plugin-core-public.savedobjectsfindoptions.type.md) | string | string[]
| |
+| [typeToNamespacesMap](./kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md) | Map<string, string[] | undefined>
| This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the type
and namespaces
fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. |
diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md
new file mode 100644
index 0000000000000..4af8c9ddeaff4
--- /dev/null
+++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [typeToNamespacesMap](./kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md)
+
+## SavedObjectsFindOptions.typeToNamespacesMap property
+
+This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace.
+
+Signature:
+
+```typescript
+typeToNamespacesMap?: Map;
+```
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 804c83f7c1b48..ce5c20e60ca11 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md
@@ -29,4 +29,5 @@ export interface SavedObjectsFindOptions
| [sortField](./kibana-plugin-core-server.savedobjectsfindoptions.sortfield.md) | string
| |
| [sortOrder](./kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md) | string
| |
| [type](./kibana-plugin-core-server.savedobjectsfindoptions.type.md) | string | string[]
| |
+| [typeToNamespacesMap](./kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md) | Map<string, string[] | undefined>
| This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the type
and namespaces
fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. |
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md
new file mode 100644
index 0000000000000..8bec759f05580
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [typeToNamespacesMap](./kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md)
+
+## SavedObjectsFindOptions.typeToNamespacesMap property
+
+This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace.
+
+Signature:
+
+```typescript
+typeToNamespacesMap?: Map;
+```
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 1b562263145da..d3e93e7af2aa0 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, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>;
+find(options: SavedObjectsFindOptions): Promise>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
-| { search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, } | SavedObjectsFindOptions
| |
+| options | 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 14d3741425987..1d11d5262a9c4 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md
@@ -24,7 +24,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, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | |
+| [find(options)](./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/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md
new file mode 100644
index 0000000000000..40e865cb02ce8
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md
@@ -0,0 +1,13 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) > [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md)
+
+## SavedObjectsUtils.createEmptyFindResponse property
+
+Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers.
+
+Signature:
+
+```typescript
+static createEmptyFindResponse: ({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse;
+```
diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md
index e365dfbcb5142..83831f65bd41a 100644
--- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md
+++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md
@@ -15,6 +15,7 @@ export declare class SavedObjectsUtils
| Property | Modifiers | Type | Description |
| --- | --- | --- | --- |
+| [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md) | static
| <T>({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse<T>
| Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. |
| [namespaceIdToString](./kibana-plugin-core-server.savedobjectsutils.namespaceidtostring.md) | static
| (namespace?: string | undefined) => string
| Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with the exception of the undefined
namespace ID (which has a namespace string of 'default'
). |
| [namespaceStringToId](./kibana-plugin-core-server.savedobjectsutils.namespacestringtoid.md) | static
| (namespace: string) => string | undefined
| Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with the exception of the 'default'
namespace string (which has a namespace ID of undefined
). |
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index 1c17be50454c5..7179c6cf8b133 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -1079,6 +1079,7 @@ export interface SavedObjectsFindOptions {
sortOrder?: string;
// (undocumented)
type: string | string[];
+ typeToNamespacesMap?: Map;
}
// @public
diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts
index 5a8949ca2f55f..6a10eb44d9ca4 100644
--- a/src/core/public/saved_objects/saved_objects_client.ts
+++ b/src/core/public/saved_objects/saved_objects_client.ts
@@ -34,7 +34,7 @@ import { HttpFetchOptions, HttpSetup } from '../http';
type SavedObjectsFindOptions = Omit<
SavedObjectFindOptionsServer,
- 'namespace' | 'sortOrder' | 'rootSearchFields'
+ 'sortOrder' | 'rootSearchFields' | 'typeToNamespacesMap'
>;
type PromiseType> = T extends Promise ? U : never;
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 352ce4c1c16eb..0e72ad2fec06c 100644
--- a/src/core/server/saved_objects/service/lib/repository.test.js
+++ b/src/core/server/saved_objects/service/lib/repository.test.js
@@ -2477,6 +2477,33 @@ describe('SavedObjectsRepository', () => {
expect(client.search).not.toHaveBeenCalled();
});
+ it(`throws when namespaces is an empty array`, async () => {
+ await expect(
+ savedObjectsRepository.find({ type: 'foo', namespaces: [] })
+ ).rejects.toThrowError('options.namespaces cannot be an empty array');
+ expect(client.search).not.toHaveBeenCalled();
+ });
+
+ it(`throws when type is not falsy and typeToNamespacesMap is defined`, async () => {
+ await expect(
+ savedObjectsRepository.find({ type: 'foo', typeToNamespacesMap: new Map() })
+ ).rejects.toThrowError(
+ 'options.type must be an empty string when options.typeToNamespacesMap is used'
+ );
+ expect(client.search).not.toHaveBeenCalled();
+ });
+
+ it(`throws when type is not an empty array and typeToNamespacesMap is defined`, async () => {
+ const test = async (args) => {
+ await expect(savedObjectsRepository.find(args)).rejects.toThrowError(
+ 'options.namespaces must be an empty array when options.typeToNamespacesMap is used'
+ );
+ expect(client.search).not.toHaveBeenCalled();
+ };
+ await test({ type: '', typeToNamespacesMap: new Map() });
+ await test({ type: '', namespaces: ['some-ns'], typeToNamespacesMap: new Map() });
+ });
+
it(`throws when searchFields is defined but not an array`, async () => {
await expect(
savedObjectsRepository.find({ type, searchFields: 'string' })
@@ -2493,7 +2520,7 @@ describe('SavedObjectsRepository', () => {
it(`throws when KQL filter syntax is invalid`, async () => {
const findOpts = {
- namespace,
+ namespaces: [namespace],
search: 'foo*',
searchFields: ['foo'],
type: ['dashboard'],
@@ -2577,38 +2604,70 @@ describe('SavedObjectsRepository', () => {
const test = async (types) => {
const result = await savedObjectsRepository.find({ type: types });
expect(result).toEqual(expect.objectContaining({ saved_objects: [] }));
+ expect(client.search).not.toHaveBeenCalled();
};
await test('unknownType');
await test(HIDDEN_TYPE);
await test(['unknownType', HIDDEN_TYPE]);
});
+
+ it(`should return empty results when attempting to find only invalid or hidden types using typeToNamespacesMap`, async () => {
+ const test = async (types) => {
+ const result = await savedObjectsRepository.find({
+ typeToNamespacesMap: new Map(types.map((x) => [x, undefined])),
+ type: '',
+ namespaces: [],
+ });
+ expect(result).toEqual(expect.objectContaining({ saved_objects: [] }));
+ expect(client.search).not.toHaveBeenCalled();
+ };
+
+ await test(['unknownType']);
+ await test([HIDDEN_TYPE]);
+ await test(['unknownType', HIDDEN_TYPE]);
+ });
});
describe('search dsl', () => {
- it(`passes mappings, registry, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl`, async () => {
+ const commonOptions = {
+ type: [type], // cannot be used when `typeToNamespacesMap` is present
+ namespaces: [namespace], // cannot be used when `typeToNamespacesMap` is present
+ search: 'foo*',
+ searchFields: ['foo'],
+ sortField: 'name',
+ sortOrder: 'desc',
+ defaultSearchOperator: 'AND',
+ hasReference: {
+ type: 'foo',
+ id: '1',
+ },
+ kueryNode: undefined,
+ };
+
+ it(`passes mappings, registry, and search options to getSearchDsl`, async () => {
+ await findSuccess(commonOptions, namespace);
+ expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, commonOptions);
+ });
+
+ it(`accepts typeToNamespacesMap`, async () => {
const relevantOpts = {
- namespaces: [namespace],
- search: 'foo*',
- searchFields: ['foo'],
- type: [type],
- sortField: 'name',
- sortOrder: 'desc',
- defaultSearchOperator: 'AND',
- hasReference: {
- type: 'foo',
- id: '1',
- },
- kueryNode: undefined,
+ ...commonOptions,
+ type: '',
+ namespaces: [],
+ typeToNamespacesMap: new Map([[type, [namespace]]]), // can only be used when `type` is falsy and `namespaces` is an empty array
};
await findSuccess(relevantOpts, namespace);
- expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, relevantOpts);
+ expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, {
+ ...relevantOpts,
+ type: [type],
+ });
});
it(`accepts KQL expression filter and passes KueryNode to getSearchDsl`, async () => {
const findOpts = {
- namespace,
+ namespaces: [namespace],
search: 'foo*',
searchFields: ['foo'],
type: ['dashboard'],
@@ -2649,7 +2708,7 @@ describe('SavedObjectsRepository', () => {
it(`accepts KQL KueryNode filter and passes KueryNode to getSearchDsl`, async () => {
const findOpts = {
- namespace,
+ namespaces: [namespace],
search: 'foo*',
searchFields: ['foo'],
type: ['dashboard'],
diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts
index 125f97e7feb11..a83c86e585628 100644
--- a/src/core/server/saved_objects/service/lib/repository.ts
+++ b/src/core/server/saved_objects/service/lib/repository.ts
@@ -67,7 +67,7 @@ import {
} from '../../types';
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
import { validateConvertFilterToKueryNode } from './filter_utils';
-import { SavedObjectsUtils } from './utils';
+import { FIND_DEFAULT_PAGE, FIND_DEFAULT_PER_PAGE, SavedObjectsUtils } from './utils';
// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository
// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient.
@@ -693,37 +693,51 @@ export class SavedObjectsRepository {
* @property {string} [options.preference]
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
*/
- async find({
- search,
- defaultSearchOperator = 'OR',
- searchFields,
- rootSearchFields,
- hasReference,
- page = 1,
- perPage = 20,
- sortField,
- sortOrder,
- fields,
- namespaces,
- type,
- filter,
- preference,
- }: SavedObjectsFindOptions): Promise> {
- if (!type) {
+ async find(options: SavedObjectsFindOptions): Promise> {
+ const {
+ search,
+ defaultSearchOperator = 'OR',
+ searchFields,
+ rootSearchFields,
+ hasReference,
+ page = FIND_DEFAULT_PAGE,
+ perPage = FIND_DEFAULT_PER_PAGE,
+ sortField,
+ sortOrder,
+ fields,
+ namespaces,
+ type,
+ typeToNamespacesMap,
+ filter,
+ preference,
+ } = options;
+
+ if (!type && !typeToNamespacesMap) {
throw SavedObjectsErrorHelpers.createBadRequestError(
'options.type must be a string or an array of strings'
);
+ } else if (namespaces?.length === 0 && !typeToNamespacesMap) {
+ throw SavedObjectsErrorHelpers.createBadRequestError(
+ 'options.namespaces cannot be an empty array'
+ );
+ } else if (type && typeToNamespacesMap) {
+ throw SavedObjectsErrorHelpers.createBadRequestError(
+ 'options.type must be an empty string when options.typeToNamespacesMap is used'
+ );
+ } else if ((!namespaces || namespaces?.length) && typeToNamespacesMap) {
+ throw SavedObjectsErrorHelpers.createBadRequestError(
+ 'options.namespaces must be an empty array when options.typeToNamespacesMap is used'
+ );
}
- const types = Array.isArray(type) ? type : [type];
+ const types = type
+ ? Array.isArray(type)
+ ? type
+ : [type]
+ : Array.from(typeToNamespacesMap!.keys());
const allowedTypes = types.filter((t) => this._allowedTypes.includes(t));
if (allowedTypes.length === 0) {
- return {
- page,
- per_page: perPage,
- total: 0,
- saved_objects: [],
- };
+ return SavedObjectsUtils.createEmptyFindResponse(options);
}
if (searchFields && !Array.isArray(searchFields)) {
@@ -766,6 +780,7 @@ export class SavedObjectsRepository {
sortField,
sortOrder,
namespaces,
+ typeToNamespacesMap,
hasReference,
kueryNode,
}),
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 4adc92df31805..e13c67a720400 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
@@ -50,6 +50,40 @@ const ALL_TYPE_SUBSETS = ALL_TYPES.reduce(
.filter((x) => x.length) // exclude empty set
.map((x) => (x.length === 1 ? x[0] : x)); // if a subset is a single string, destructure it
+const createTypeClause = (type: string, namespaces?: string[]) => {
+ if (registry.isMultiNamespace(type)) {
+ return {
+ bool: {
+ must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]),
+ must_not: [{ exists: { field: 'namespace' } }],
+ },
+ };
+ } 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: [{ term: { type } }],
+ should: expect.arrayContaining(should),
+ minimum_should_match: 1,
+ must_not: [{ exists: { field: 'namespaces' } }],
+ },
+ };
+ }
+ // isNamespaceAgnostic
+ return {
+ bool: expect.objectContaining({
+ must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }],
+ }),
+ };
+};
+
/**
* Note: these tests cases are defined in the order they appear in the source code, for readability's sake
*/
@@ -198,40 +232,6 @@ describe('#getQueryParams', () => {
});
describe('`namespaces` parameter', () => {
- const createTypeClause = (type: string, namespaces?: string[]) => {
- if (registry.isMultiNamespace(type)) {
- return {
- bool: {
- must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]),
- must_not: [{ exists: { field: 'namespace' } }],
- },
- };
- } 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: [{ term: { type } }],
- should: expect.arrayContaining(should),
- minimum_should_match: 1,
- must_not: [{ exists: { field: 'namespaces' } }],
- },
- };
- }
- // isNamespaceAgnostic
- return {
- bool: expect.objectContaining({
- must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }],
- }),
- };
- };
-
const expectResult = (result: Result, ...typeClauses: any) => {
expect(result.query.bool.filter).toEqual(
expect.arrayContaining([
@@ -281,6 +281,37 @@ describe('#getQueryParams', () => {
test(['default']);
});
});
+
+ describe('`typeToNamespacesMap` parameter', () => {
+ const expectResult = (result: Result, ...typeClauses: any) => {
+ expect(result.query.bool.filter).toEqual(
+ expect.arrayContaining([
+ { bool: expect.objectContaining({ should: typeClauses, minimum_should_match: 1 }) },
+ ])
+ );
+ };
+
+ it('supersedes `type` and `namespaces` parameters', () => {
+ const result = getQueryParams({
+ mappings,
+ registry,
+ type: ['pending', 'saved', 'shared', 'global'],
+ namespaces: ['foo', 'bar', 'default'],
+ typeToNamespacesMap: new Map([
+ ['pending', ['foo']], // 'pending' is only authorized in the 'foo' namespace
+ // 'saved' is not authorized in any namespaces
+ ['shared', ['bar', 'default']], // 'shared' is only authorized in the 'bar' and 'default' namespaces
+ ['global', ['foo', 'bar', 'default']], // 'global' is authorized in all namespaces (which are ignored anyway)
+ ]),
+ });
+ expectResult(
+ result,
+ createTypeClause('pending', ['foo']),
+ createTypeClause('shared', ['bar', 'default']),
+ createTypeClause('global')
+ );
+ });
+ });
});
describe('search clause (query.bool.must.simple_query_string)', () => {
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 642d51c70766e..eaddc05fa921c 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
@@ -129,6 +129,7 @@ interface QueryParams {
registry: ISavedObjectTypeRegistry;
namespaces?: string[];
type?: string | string[];
+ typeToNamespacesMap?: Map;
search?: string;
searchFields?: string[];
rootSearchFields?: string[];
@@ -145,6 +146,7 @@ export function getQueryParams({
registry,
namespaces,
type,
+ typeToNamespacesMap,
search,
searchFields,
rootSearchFields,
@@ -152,7 +154,10 @@ export function getQueryParams({
hasReference,
kueryNode,
}: QueryParams) {
- const types = getTypes(mappings, type);
+ const types = getTypes(
+ mappings,
+ typeToNamespacesMap ? Array.from(typeToNamespacesMap.keys()) : type
+ );
// A de-duplicated set of namespaces makes for a more effecient query.
//
@@ -163,9 +168,12 @@ export function getQueryParams({
// 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((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x))))
- : undefined;
+ const normalizeNamespaces = (namespacesToNormalize?: string[]) =>
+ namespacesToNormalize
+ ? Array.from(
+ new Set(namespacesToNormalize.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x)))
+ )
+ : undefined;
const bool: any = {
filter: [
@@ -197,9 +205,12 @@ export function getQueryParams({
},
]
: undefined,
- should: types.map((shouldType) =>
- getClauseForType(registry, normalizedNamespaces, shouldType)
- ),
+ should: types.map((shouldType) => {
+ const normalizedNamespaces = normalizeNamespaces(
+ typeToNamespacesMap ? typeToNamespacesMap.get(shouldType) : namespaces
+ );
+ return 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 62e629ad33cc8..7276e505bce7d 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,10 +57,11 @@ describe('getSearchDsl', () => {
});
describe('passes control', () => {
- it('passes (mappings, schema, namespaces, type, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => {
+ it('passes (mappings, schema, namespaces, type, typeToNamespacesMap, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => {
const opts = {
namespaces: ['foo-namespace'],
type: 'foo',
+ typeToNamespacesMap: new Map(),
search: 'bar',
searchFields: ['baz'],
rootSearchFields: ['qux'],
@@ -78,6 +79,7 @@ describe('getSearchDsl', () => {
registry,
namespaces: opts.namespaces,
type: opts.type,
+ typeToNamespacesMap: opts.typeToNamespacesMap,
search: opts.search,
searchFields: opts.searchFields,
rootSearchFields: opts.rootSearchFields,
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 aa79a10b2a9be..858770579fb9e 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
@@ -35,6 +35,7 @@ interface GetSearchDslOptions {
sortField?: string;
sortOrder?: string;
namespaces?: string[];
+ typeToNamespacesMap?: Map;
hasReference?: {
type: string;
id: string;
@@ -56,6 +57,7 @@ export function getSearchDsl(
sortField,
sortOrder,
namespaces,
+ typeToNamespacesMap,
hasReference,
kueryNode,
} = options;
@@ -74,6 +76,7 @@ export function getSearchDsl(
registry,
namespaces,
type,
+ typeToNamespacesMap,
search,
searchFields,
rootSearchFields,
diff --git a/src/core/server/saved_objects/service/lib/utils.test.ts b/src/core/server/saved_objects/service/lib/utils.test.ts
index ea4fa68242bea..ac06ca9275783 100644
--- a/src/core/server/saved_objects/service/lib/utils.test.ts
+++ b/src/core/server/saved_objects/service/lib/utils.test.ts
@@ -17,10 +17,11 @@
* under the License.
*/
+import { SavedObjectsFindOptions } from '../../types';
import { SavedObjectsUtils } from './utils';
describe('SavedObjectsUtils', () => {
- const { namespaceIdToString, namespaceStringToId } = SavedObjectsUtils;
+ const { namespaceIdToString, namespaceStringToId, createEmptyFindResponse } = SavedObjectsUtils;
describe('#namespaceIdToString', () => {
it('converts `undefined` to default namespace string', () => {
@@ -54,4 +55,26 @@ describe('SavedObjectsUtils', () => {
test('');
});
});
+
+ describe('#createEmptyFindResponse', () => {
+ it('returns expected result', () => {
+ const options = {} as SavedObjectsFindOptions;
+ expect(createEmptyFindResponse(options)).toEqual({
+ page: 1,
+ per_page: 20,
+ total: 0,
+ saved_objects: [],
+ });
+ });
+
+ it('handles `page` field', () => {
+ const options = { page: 42 } as SavedObjectsFindOptions;
+ expect(createEmptyFindResponse(options).page).toEqual(42);
+ });
+
+ it('handles `perPage` field', () => {
+ const options = { perPage: 42 } as SavedObjectsFindOptions;
+ expect(createEmptyFindResponse(options).per_page).toEqual(42);
+ });
+ });
});
diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts
index 6101ad57cc401..3efe8614da1d7 100644
--- a/src/core/server/saved_objects/service/lib/utils.ts
+++ b/src/core/server/saved_objects/service/lib/utils.ts
@@ -17,7 +17,12 @@
* under the License.
*/
+import { SavedObjectsFindOptions } from '../../types';
+import { SavedObjectsFindResponse } from '..';
+
export const DEFAULT_NAMESPACE_STRING = 'default';
+export const FIND_DEFAULT_PAGE = 1;
+export const FIND_DEFAULT_PER_PAGE = 20;
/**
* @public
@@ -50,4 +55,17 @@ export class SavedObjectsUtils {
return namespace !== DEFAULT_NAMESPACE_STRING ? namespace : undefined;
};
+
+ /**
+ * Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers.
+ */
+ public static createEmptyFindResponse = ({
+ page = FIND_DEFAULT_PAGE,
+ perPage = FIND_DEFAULT_PER_PAGE,
+ }: SavedObjectsFindOptions): SavedObjectsFindResponse => ({
+ page,
+ per_page: perPage,
+ total: 0,
+ saved_objects: [],
+ });
}
diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts
index 1885f5ec50139..01128e4f8cf51 100644
--- a/src/core/server/saved_objects/types.ts
+++ b/src/core/server/saved_objects/types.ts
@@ -89,6 +89,14 @@ export interface SavedObjectsFindOptions {
defaultSearchOperator?: 'AND' | 'OR';
filter?: string | KueryNode;
namespaces?: string[];
+ /**
+ * This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved
+ * object client wrapper.
+ * If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query.
+ * Any types that are not included in this map will be excluded entirely.
+ * If a type is included but its value is undefined, the operation will search for that type in the Default namespace.
+ */
+ typeToNamespacesMap?: Map;
/** 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 d755ef3e1b676..8a764d9bd2f66 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -2177,6 +2177,7 @@ export interface SavedObjectsFindOptions {
sortOrder?: string;
// (undocumented)
type: string | string[];
+ typeToNamespacesMap?: Map;
}
// @public
@@ -2388,7 +2389,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, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>;
+ find(options: SavedObjectsFindOptions): Promise>;
get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>;
incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise;
update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>;
@@ -2496,6 +2497,7 @@ export interface SavedObjectsUpdateResponse extends Omit({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse;
static namespaceIdToString: (namespace?: string | undefined) => string;
static namespaceStringToId: (namespace: string) => string | undefined;
}
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 7ada34ff5ccac..86d1b68ba761e 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
@@ -609,22 +609,83 @@ describe('#find', () => {
await expectGeneralError(client.find, { type: type1 });
});
- test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => {
+ test(`returns empty result when unauthorized`, async () => {
+ clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation(
+ getMockCheckPrivilegesFailure
+ );
+
const options = Object.freeze({ type: type1, namespaces: ['some-ns'] });
- await expectForbiddenError(client.find, { options });
- });
+ const result = await client.find(options);
- test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => {
- const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] });
- await expectForbiddenError(client.find, { options });
+ expect(clientOpts.baseClient.find).not.toHaveBeenCalled();
+ expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1);
+ expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith(
+ USERNAME,
+ 'find',
+ [type1],
+ options.namespaces,
+ [{ spaceId: 'some-ns', privilege: 'mock-saved_object:foo/find' }],
+ { options }
+ );
+ expect(result).toEqual({ page: 1, per_page: 20, total: 0, saved_objects: [] });
});
- test(`returns result of baseClient.find when authorized`, async () => {
+ test(`returns result of baseClient.find when fully authorized`, async () => {
const apiCallReturnValue = { saved_objects: [], foo: 'bar' };
clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any);
const options = Object.freeze({ type: type1, namespaces: ['some-ns'] });
const result = await expectSuccess(client.find, { options });
+ expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({
+ ...options,
+ typeToNamespacesMap: undefined,
+ });
+ expect(result).toEqual(apiCallReturnValue);
+ });
+
+ test(`returns result of baseClient.find when partially authorized`, async () => {
+ clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({
+ hasAllRequested: false,
+ username: USERNAME,
+ privileges: {
+ kibana: [
+ { resource: 'some-ns', privilege: 'mock-saved_object:foo/find', authorized: true },
+ { resource: 'some-ns', privilege: 'mock-saved_object:bar/find', authorized: true },
+ { resource: 'some-ns', privilege: 'mock-saved_object:baz/find', authorized: false },
+ { resource: 'some-ns', privilege: 'mock-saved_object:qux/find', authorized: false },
+ { resource: 'another-ns', privilege: 'mock-saved_object:foo/find', authorized: true },
+ { resource: 'another-ns', privilege: 'mock-saved_object:bar/find', authorized: false },
+ { resource: 'another-ns', privilege: 'mock-saved_object:baz/find', authorized: true },
+ { resource: 'another-ns', privilege: 'mock-saved_object:qux/find', authorized: false },
+ { resource: 'forbidden-ns', privilege: 'mock-saved_object:foo/find', authorized: false },
+ { resource: 'forbidden-ns', privilege: 'mock-saved_object:bar/find', authorized: false },
+ { resource: 'forbidden-ns', privilege: 'mock-saved_object:baz/find', authorized: false },
+ { resource: 'forbidden-ns', privilege: 'mock-saved_object:qux/find', authorized: false },
+ ],
+ },
+ });
+
+ const apiCallReturnValue = { saved_objects: [], foo: 'bar' };
+ clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any);
+
+ const options = Object.freeze({
+ type: ['foo', 'bar', 'baz', 'qux'],
+ namespaces: ['some-ns', 'another-ns', 'forbidden-ns'],
+ });
+ const result = await client.find(options);
+ // 'expect(clientOpts.baseClient.find).toHaveBeenCalledWith' resulted in false negatives, resorting to manually comparing mock call args
+ expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({
+ ...options,
+ typeToNamespacesMap: new Map([
+ ['foo', ['some-ns', 'another-ns']],
+ ['bar', ['some-ns']],
+ ['baz', ['another-ns']],
+ // qux is not authorized, so there is no entry for it
+ // forbidden-ns is completely forbidden, so there are no entries with this namespace
+ ]),
+ type: '',
+ namespaces: [],
+ });
expect(result).toEqual(apiCallReturnValue);
});
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 16e52c69f274f..f5de8f4b226f3 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
@@ -16,6 +16,7 @@ import {
SavedObjectsUpdateOptions,
SavedObjectsAddToNamespacesOptions,
SavedObjectsDeleteFromNamespacesOptions,
+ SavedObjectsUtils,
} from '../../../../../src/core/server';
import { SecurityAuditLogger } from '../audit';
import { Actions, CheckSavedObjectsPrivileges } from '../authorization';
@@ -39,8 +40,19 @@ interface SavedObjectsNamespaces {
saved_objects: SavedObjectNamespaces[];
}
-function uniq(arr: T[]): T[] {
- return Array.from(new Set(arr));
+interface EnsureAuthorizedOptions {
+ args?: Record;
+ auditAction?: string;
+ requireFullAuthorization?: boolean;
+}
+
+interface EnsureAuthorizedResult {
+ status: 'fully_authorized' | 'partially_authorized' | 'unauthorized';
+ typeMap: Map;
+}
+interface EnsureAuthorizedTypeResult {
+ authorizedSpaces: string[];
+ isGloballyAuthorized?: boolean;
}
export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract {
@@ -72,7 +84,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
attributes: T = {} as T,
options: SavedObjectsCreateOptions = {}
) {
- await this.ensureAuthorized(type, 'create', options.namespace, { type, attributes, options });
+ const args = { type, attributes, options };
+ await this.ensureAuthorized(type, 'create', options.namespace, { args });
const savedObject = await this.baseClient.create(type, attributes, options);
return await this.redactSavedObjectNamespaces(savedObject);
@@ -82,9 +95,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
objects: SavedObjectsCheckConflictsObject[] = [],
options: SavedObjectsBaseOptions = {}
) {
- const types = this.getUniqueObjectTypes(objects);
const args = { objects, options };
- await this.ensureAuthorized(types, 'bulk_create', options.namespace, args, 'checkConflicts');
+ const types = this.getUniqueObjectTypes(objects);
+ await this.ensureAuthorized(types, 'bulk_create', options.namespace, {
+ args,
+ auditAction: 'checkConflicts',
+ });
const response = await this.baseClient.checkConflicts(objects, options);
return response;
@@ -94,11 +110,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
objects: Array>,
options: SavedObjectsBaseOptions = {}
) {
+ const args = { objects, options };
await this.ensureAuthorized(
this.getUniqueObjectTypes(objects),
'bulk_create',
options.namespace,
- { objects, options }
+ { args }
);
const response = await this.baseClient.bulkCreate(objects, options);
@@ -106,7 +123,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
}
public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) {
- await this.ensureAuthorized(type, 'delete', options.namespace, { type, id, options });
+ const args = { type, id, options };
+ await this.ensureAuthorized(type, 'delete', options.namespace, { args });
return await this.baseClient.delete(type, id, options);
}
@@ -121,9 +139,29 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
`_find across namespaces is not permitted when the Spaces plugin is disabled.`
);
}
- await this.ensureAuthorized(options.type, 'find', options.namespaces, { options });
+ const args = { options };
+ const { status, typeMap } = await this.ensureAuthorized(
+ options.type,
+ 'find',
+ options.namespaces,
+ { args, requireFullAuthorization: false }
+ );
+
+ if (status === 'unauthorized') {
+ // return empty response
+ return SavedObjectsUtils.createEmptyFindResponse(options);
+ }
- const response = await this.baseClient.find(options);
+ const typeToNamespacesMap = Array.from(typeMap).reduce