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/docs/user/monitoring/viewing-metrics.asciidoc b/docs/user/monitoring/viewing-metrics.asciidoc
index f35caea025cdd..0c48e3b7d011d 100644
--- a/docs/user/monitoring/viewing-metrics.asciidoc
+++ b/docs/user/monitoring/viewing-metrics.asciidoc
@@ -13,13 +13,19 @@ At a minimum, you must have monitoring data for the {es} production cluster.
Once that data exists, {kib} can display monitoring data for other products in
the cluster.
+TIP: If you use a separate monitoring cluster to store the monitoring data, it
+is strongly recommended that you use a separate {kib} instance to view it. If
+you log in to {kib} using SAML, Kerberos, PKI, OpenID Connect, or token
+authentication providers, a dedicated {kib} instance is *required*. The security
+tokens that are used in these contexts are cluster-specific, therefore you
+cannot use a single {kib} instance to connect to both production and monitoring
+clusters. For more information about the recommended configuration, see
+{ref}/monitoring-overview.html[Monitoring overview].
+
. Identify where to retrieve monitoring data from.
+
--
-The cluster that contains the monitoring data is referred to
-as the _monitoring cluster_.
-
-TIP: If the monitoring data is stored on a *dedicated* monitoring cluster, it is
+If the monitoring data is stored on a dedicated monitoring cluster, it is
accessible even when the cluster you're monitoring is not. If you have at least
a gold license, you can send data from multiple clusters to the same monitoring
cluster and view them all through the same instance of {kib}.
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/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts
index 3e065142ea101..378a6c6c12159 100644
--- a/x-pack/plugins/ingest_manager/common/constants/routes.ts
+++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts
@@ -15,9 +15,11 @@ export const LIMITED_CONCURRENCY_ROUTE_TAG = 'ingest:limited-concurrency';
// EPM API routes
const EPM_PACKAGES_MANY = `${EPM_API_ROOT}/packages`;
+const EPM_PACKAGES_BULK = `${EPM_PACKAGES_MANY}/_bulk`;
const EPM_PACKAGES_ONE = `${EPM_PACKAGES_MANY}/{pkgkey}`;
const EPM_PACKAGES_FILE = `${EPM_PACKAGES_MANY}/{pkgName}/{pkgVersion}`;
export const EPM_API_ROUTES = {
+ BULK_INSTALL_PATTERN: EPM_PACKAGES_BULK,
LIST_PATTERN: EPM_PACKAGES_MANY,
LIMITED_LIST_PATTERN: `${EPM_PACKAGES_MANY}/limited`,
INFO_PATTERN: EPM_PACKAGES_ONE,
diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts
index b7521f95b4f83..ec7c0ee850834 100644
--- a/x-pack/plugins/ingest_manager/common/services/routes.ts
+++ b/x-pack/plugins/ingest_manager/common/services/routes.ts
@@ -46,6 +46,10 @@ export const epmRouteService = {
); // trim trailing slash
},
+ getBulkInstallPath: () => {
+ return EPM_API_ROUTES.BULK_INSTALL_PATTERN;
+ },
+
getRemovePath: (pkgkey: string) => {
return EPM_API_ROUTES.DELETE_PATTERN.replace('{pkgkey}', pkgkey).replace(/\/$/, ''); // trim trailing slash
},
diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts
index 54e767fee4b22..7ed2fed91aa93 100644
--- a/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts
+++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts
@@ -71,6 +71,30 @@ export interface InstallPackageResponse {
response: AssetReference[];
}
+export interface IBulkInstallPackageError {
+ name: string;
+ statusCode: number;
+ error: string | Error;
+}
+
+export interface BulkInstallPackageInfo {
+ name: string;
+ newVersion: string;
+ // this will be null if no package was present before the upgrade (aka it was an install)
+ oldVersion: string | null;
+ assets: AssetReference[];
+}
+
+export interface BulkInstallPackagesResponse {
+ response: Array;
+}
+
+export interface BulkInstallPackagesRequest {
+ body: {
+ packages: string[];
+ };
+}
+
export interface MessageResponse {
response: string;
}
diff --git a/x-pack/plugins/ingest_manager/server/errors/handlers.ts b/x-pack/plugins/ingest_manager/server/errors/handlers.ts
index 9f776565cf262..b621f2dd29331 100644
--- a/x-pack/plugins/ingest_manager/server/errors/handlers.ts
+++ b/x-pack/plugins/ingest_manager/server/errors/handlers.ts
@@ -56,10 +56,7 @@ const getHTTPResponseCode = (error: IngestManagerError): number => {
return 400; // Bad Request
};
-export const defaultIngestErrorHandler: IngestErrorHandler = async ({
- error,
- response,
-}: IngestErrorHandlerParams): Promise => {
+export function ingestErrorToResponseOptions(error: IngestErrorHandlerParams['error']) {
const logger = appContextService.getLogger();
if (isLegacyESClientError(error)) {
// there was a problem communicating with ES (e.g. via `callCluster`)
@@ -72,36 +69,44 @@ export const defaultIngestErrorHandler: IngestErrorHandler = async ({
logger.error(message);
- return response.customError({
+ return {
statusCode: error?.statusCode || error.status,
body: { message },
- });
+ };
}
// our "expected" errors
if (error instanceof IngestManagerError) {
// only log the message
logger.error(error.message);
- return response.customError({
+ return {
statusCode: getHTTPResponseCode(error),
body: { message: error.message },
- });
+ };
}
// handle any older Boom-based errors or the few places our app uses them
if (isBoom(error)) {
// only log the message
logger.error(error.output.payload.message);
- return response.customError({
+ return {
statusCode: error.output.statusCode,
body: { message: error.output.payload.message },
- });
+ };
}
// not sure what type of error this is. log as much as possible
logger.error(error);
- return response.customError({
+ return {
statusCode: 500,
body: { message: error.message },
- });
+ };
+}
+
+export const defaultIngestErrorHandler: IngestErrorHandler = async ({
+ error,
+ response,
+}: IngestErrorHandlerParams): Promise => {
+ const options = ingestErrorToResponseOptions(error);
+ return response.customError(options);
};
diff --git a/x-pack/plugins/ingest_manager/server/errors/index.ts b/x-pack/plugins/ingest_manager/server/errors/index.ts
index 5e36a2ec9a884..f495bf551dcff 100644
--- a/x-pack/plugins/ingest_manager/server/errors/index.ts
+++ b/x-pack/plugins/ingest_manager/server/errors/index.ts
@@ -5,7 +5,7 @@
*/
/* eslint-disable max-classes-per-file */
-export { defaultIngestErrorHandler } from './handlers';
+export { defaultIngestErrorHandler, ingestErrorToResponseOptions } from './handlers';
export class IngestManagerError extends Error {
constructor(message?: string) {
diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts
index c40e0e4ac5c0b..7ae896c1f30a6 100644
--- a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts
@@ -5,7 +5,6 @@
*/
import { TypeOf } from '@kbn/config-schema';
import { RequestHandler, CustomHttpResponseOptions } from 'src/core/server';
-import { appContextService } from '../../services';
import {
GetInfoResponse,
InstallPackageResponse,
@@ -14,6 +13,7 @@ import {
GetCategoriesResponse,
GetPackagesResponse,
GetLimitedPackagesResponse,
+ BulkInstallPackagesResponse,
} from '../../../common';
import {
GetCategoriesRequestSchema,
@@ -23,6 +23,7 @@ import {
InstallPackageFromRegistryRequestSchema,
InstallPackageByUploadRequestSchema,
DeletePackageRequestSchema,
+ BulkUpgradePackagesFromRegistryRequestSchema,
} from '../../types';
import {
getCategories,
@@ -34,9 +35,12 @@ import {
getLimitedPackages,
getInstallationObject,
} from '../../services/epm/packages';
-import { IngestManagerError, defaultIngestErrorHandler } from '../../errors';
+import { defaultIngestErrorHandler } from '../../errors';
import { splitPkgKey } from '../../services/epm/registry';
-import { getInstallType } from '../../services/epm/packages/install';
+import {
+ handleInstallPackageFailure,
+ bulkInstallPackages,
+} from '../../services/epm/packages/install';
export const getCategoriesHandler: RequestHandler<
undefined,
@@ -136,13 +140,11 @@ export const installPackageFromRegistryHandler: RequestHandler<
undefined,
TypeOf
> = async (context, request, response) => {
- const logger = appContextService.getLogger();
const savedObjectsClient = context.core.savedObjects.client;
const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser;
const { pkgkey } = request.params;
const { pkgName, pkgVersion } = splitPkgKey(pkgkey);
const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName });
- const installType = getInstallType({ pkgVersion, installedPkg });
try {
const res = await installPackage({
savedObjectsClient,
@@ -155,36 +157,38 @@ export const installPackageFromRegistryHandler: RequestHandler<
};
return response.ok({ body });
} catch (e) {
- // could have also done `return defaultIngestErrorHandler({ error: e, response })` at each of the returns,
- // but doing it this way will log the outer/install errors before any inner/rollback errors
const defaultResult = await defaultIngestErrorHandler({ error: e, response });
- if (e instanceof IngestManagerError) {
- return defaultResult;
- }
+ await handleInstallPackageFailure({
+ savedObjectsClient,
+ error: e,
+ pkgName,
+ pkgVersion,
+ installedPkg,
+ callCluster,
+ });
- // if there is an unknown server error, uninstall any package assets or reinstall the previous version if update
- try {
- if (installType === 'install' || installType === 'reinstall') {
- logger.error(`uninstalling ${pkgkey} after error installing`);
- await removeInstallation({ savedObjectsClient, pkgkey, callCluster });
- }
- if (installType === 'update') {
- // @ts-ignore getInstallType ensures we have installedPkg
- const prevVersion = `${pkgName}-${installedPkg.attributes.version}`;
- logger.error(`rolling back to ${prevVersion} after error installing ${pkgkey}`);
- await installPackage({
- savedObjectsClient,
- pkgkey: prevVersion,
- callCluster,
- });
- }
- } catch (error) {
- logger.error(`failed to uninstall or rollback package after installation error ${error}`);
- }
return defaultResult;
}
};
+export const bulkInstallPackagesFromRegistryHandler: RequestHandler<
+ undefined,
+ undefined,
+ TypeOf
+> = async (context, request, response) => {
+ const savedObjectsClient = context.core.savedObjects.client;
+ const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser;
+ const res = await bulkInstallPackages({
+ savedObjectsClient,
+ callCluster,
+ packagesToUpgrade: request.body.packages,
+ });
+ const body: BulkInstallPackagesResponse = {
+ response: res,
+ };
+ return response.ok({ body });
+};
+
export const installPackageByUploadHandler: RequestHandler<
undefined,
undefined,
diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts
index 9048652f0e8a9..eaf61335b5e06 100644
--- a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts
+++ b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts
@@ -14,6 +14,7 @@ import {
installPackageFromRegistryHandler,
installPackageByUploadHandler,
deletePackageHandler,
+ bulkInstallPackagesFromRegistryHandler,
} from './handlers';
import {
GetCategoriesRequestSchema,
@@ -23,6 +24,7 @@ import {
InstallPackageFromRegistryRequestSchema,
InstallPackageByUploadRequestSchema,
DeletePackageRequestSchema,
+ BulkUpgradePackagesFromRegistryRequestSchema,
} from '../../types';
const MAX_FILE_SIZE_BYTES = 104857600; // 100MB
@@ -82,6 +84,15 @@ export const registerRoutes = (router: IRouter) => {
installPackageFromRegistryHandler
);
+ router.post(
+ {
+ path: EPM_API_ROUTES.BULK_INSTALL_PATTERN,
+ validate: BulkUpgradePackagesFromRegistryRequestSchema,
+ options: { tags: [`access:${PLUGIN_ID}-all`] },
+ },
+ bulkInstallPackagesFromRegistryHandler
+ );
+
router.post(
{
path: EPM_API_ROUTES.INSTALL_BY_UPLOAD_PATTERN,
diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts
index 54b9c4d3fbb17..800151a41a429 100644
--- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts
+++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts
@@ -6,6 +6,9 @@
import { SavedObject, SavedObjectsClientContract } from 'src/core/server';
import semver from 'semver';
+import Boom from 'boom';
+import { UnwrapPromise } from '@kbn/utility-types';
+import { BulkInstallPackageInfo, IBulkInstallPackageError } from '../../../../common';
import { PACKAGES_SAVED_OBJECT_TYPE, MAX_TIME_COMPLETE_INSTALL } from '../../../constants';
import {
AssetReference,
@@ -32,10 +35,15 @@ import {
ArchiveAsset,
} from '../kibana/assets/install';
import { updateCurrentWriteIndices } from '../elasticsearch/template/template';
-import { deleteKibanaSavedObjectsAssets } from './remove';
-import { PackageOutdatedError } from '../../../errors';
+import { deleteKibanaSavedObjectsAssets, removeInstallation } from './remove';
+import {
+ IngestManagerError,
+ PackageOutdatedError,
+ ingestErrorToResponseOptions,
+} from '../../../errors';
import { getPackageSavedObjects } from './get';
import { installTransformForDataset } from '../elasticsearch/transform/install';
+import { appContextService } from '../../app_context';
export async function installLatestPackage(options: {
savedObjectsClient: SavedObjectsClientContract;
@@ -94,17 +102,185 @@ export async function ensureInstalledPackage(options: {
return installation;
}
-export async function installPackage({
+export async function handleInstallPackageFailure({
savedObjectsClient,
- pkgkey,
+ error,
+ pkgName,
+ pkgVersion,
+ installedPkg,
callCluster,
- force = false,
}: {
+ savedObjectsClient: SavedObjectsClientContract;
+ error: IngestManagerError | Boom | Error;
+ pkgName: string;
+ pkgVersion: string;
+ installedPkg: SavedObject | undefined;
+ callCluster: CallESAsCurrentUser;
+}) {
+ if (error instanceof IngestManagerError) {
+ return;
+ }
+ const logger = appContextService.getLogger();
+ const pkgkey = Registry.pkgToPkgKey({
+ name: pkgName,
+ version: pkgVersion,
+ });
+
+ // if there is an unknown server error, uninstall any package assets or reinstall the previous version if update
+ try {
+ const installType = getInstallType({ pkgVersion, installedPkg });
+ if (installType === 'install' || installType === 'reinstall') {
+ logger.error(`uninstalling ${pkgkey} after error installing`);
+ await removeInstallation({ savedObjectsClient, pkgkey, callCluster });
+ }
+
+ if (installType === 'update') {
+ if (!installedPkg) {
+ logger.error(
+ `failed to rollback package after installation error ${error} because saved object was undefined`
+ );
+ return;
+ }
+ const prevVersion = `${pkgName}-${installedPkg.attributes.version}`;
+ logger.error(`rolling back to ${prevVersion} after error installing ${pkgkey}`);
+ await installPackage({
+ savedObjectsClient,
+ pkgkey: prevVersion,
+ callCluster,
+ });
+ }
+ } catch (e) {
+ logger.error(`failed to uninstall or rollback package after installation error ${e}`);
+ }
+}
+
+type BulkInstallResponse = BulkInstallPackageInfo | IBulkInstallPackageError;
+function bulkInstallErrorToOptions({
+ pkgToUpgrade,
+ error,
+}: {
+ pkgToUpgrade: string;
+ error: Error;
+}): IBulkInstallPackageError {
+ const { statusCode, body } = ingestErrorToResponseOptions(error);
+ return {
+ name: pkgToUpgrade,
+ statusCode,
+ error: body.message,
+ };
+}
+
+interface UpgradePackageParams {
+ savedObjectsClient: SavedObjectsClientContract;
+ callCluster: CallESAsCurrentUser;
+ installedPkg: UnwrapPromise>;
+ latestPkg: UnwrapPromise>;
+ pkgToUpgrade: string;
+}
+async function upgradePackage({
+ savedObjectsClient,
+ callCluster,
+ installedPkg,
+ latestPkg,
+ pkgToUpgrade,
+}: UpgradePackageParams): Promise {
+ if (!installedPkg || semver.gt(latestPkg.version, installedPkg.attributes.version)) {
+ const pkgkey = Registry.pkgToPkgKey({
+ name: latestPkg.name,
+ version: latestPkg.version,
+ });
+
+ try {
+ const assets = await installPackage({ savedObjectsClient, pkgkey, callCluster });
+ return {
+ name: pkgToUpgrade,
+ newVersion: latestPkg.version,
+ oldVersion: installedPkg?.attributes.version ?? null,
+ assets,
+ };
+ } catch (installFailed) {
+ await handleInstallPackageFailure({
+ savedObjectsClient,
+ error: installFailed,
+ pkgName: latestPkg.name,
+ pkgVersion: latestPkg.version,
+ installedPkg,
+ callCluster,
+ });
+ return bulkInstallErrorToOptions({ pkgToUpgrade, error: installFailed });
+ }
+ } else {
+ // package was already at the latest version
+ return {
+ name: pkgToUpgrade,
+ newVersion: latestPkg.version,
+ oldVersion: latestPkg.version,
+ assets: [
+ ...installedPkg.attributes.installed_es,
+ ...installedPkg.attributes.installed_kibana,
+ ],
+ };
+ }
+}
+
+interface BulkInstallPackagesParams {
+ savedObjectsClient: SavedObjectsClientContract;
+ packagesToUpgrade: string[];
+ callCluster: CallESAsCurrentUser;
+}
+export async function bulkInstallPackages({
+ savedObjectsClient,
+ packagesToUpgrade,
+ callCluster,
+}: BulkInstallPackagesParams): Promise {
+ const installedAndLatestPromises = packagesToUpgrade.map((pkgToUpgrade) =>
+ Promise.all([
+ getInstallationObject({ savedObjectsClient, pkgName: pkgToUpgrade }),
+ Registry.fetchFindLatestPackage(pkgToUpgrade),
+ ])
+ );
+ const installedAndLatestResults = await Promise.allSettled(installedAndLatestPromises);
+ const installResponsePromises = installedAndLatestResults.map(async (result, index) => {
+ const pkgToUpgrade = packagesToUpgrade[index];
+ if (result.status === 'fulfilled') {
+ const [installedPkg, latestPkg] = result.value;
+ return upgradePackage({
+ savedObjectsClient,
+ callCluster,
+ installedPkg,
+ latestPkg,
+ pkgToUpgrade,
+ });
+ } else {
+ return bulkInstallErrorToOptions({ pkgToUpgrade, error: result.reason });
+ }
+ });
+ const installResults = await Promise.allSettled(installResponsePromises);
+ const installResponses = installResults.map((result, index) => {
+ const pkgToUpgrade = packagesToUpgrade[index];
+ if (result.status === 'fulfilled') {
+ return result.value;
+ } else {
+ return bulkInstallErrorToOptions({ pkgToUpgrade, error: result.reason });
+ }
+ });
+
+ return installResponses;
+}
+
+interface InstallPackageParams {
savedObjectsClient: SavedObjectsClientContract;
pkgkey: string;
callCluster: CallESAsCurrentUser;
force?: boolean;
-}): Promise {
+}
+
+export async function installPackage({
+ savedObjectsClient,
+ pkgkey,
+ callCluster,
+ force = false,
+}: InstallPackageParams): Promise {
// TODO: change epm API to /packageName/version so we don't need to do this
const { pkgName, pkgVersion } = Registry.splitPkgKey(pkgkey);
// TODO: calls to getInstallationObject, Registry.fetchInfo, and Registry.fetchFindLatestPackge
diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts
index d7a801feec34f..5d2a078374854 100644
--- a/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts
+++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts
@@ -43,6 +43,12 @@ export const InstallPackageFromRegistryRequestSchema = {
),
};
+export const BulkUpgradePackagesFromRegistryRequestSchema = {
+ body: schema.object({
+ packages: schema.arrayOf(schema.string(), { minSize: 1 }),
+ }),
+};
+
export const InstallPackageByUploadRequestSchema = {
body: schema.buffer(),
};
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